feat: 添加 Python 版本的 Gitea Webhook Ambassador
- 新增完整的 Python 实现,替代 Go 版本 - 添加 Web 登录界面和仪表板 - 实现 JWT 认证和 API 密钥管理 - 添加数据库存储功能 - 保持与 Go 版本一致的目录结构和启动脚本 - 包含完整的文档和测试脚本
This commit is contained in:
parent
843a73ef80
commit
f6c515157c
63
apps/gitea-webhook-ambassador-python/.gitignore
vendored
Normal file
63
apps/gitea-webhook-ambassador-python/.gitignore
vendored
Normal file
@ -0,0 +1,63 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/*.log
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# PID files
|
||||
*.pid
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
143
apps/gitea-webhook-ambassador-python/Makefile
Normal file
143
apps/gitea-webhook-ambassador-python/Makefile
Normal file
@ -0,0 +1,143 @@
|
||||
.PHONY: build clean test lint docker-build docker-push run help install init
|
||||
|
||||
# Variables
|
||||
APP_NAME := gitea-webhook-ambassador
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
PYTHON := python3
|
||||
PIP := pip
|
||||
VENV := venv
|
||||
CONFIG_FILE := config.yaml
|
||||
|
||||
# Python commands
|
||||
PYTHON_FILES := $(shell find . -name "*.py" -type f)
|
||||
REQUIREMENTS := requirements.txt
|
||||
|
||||
# Default target
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
# Install dependencies
|
||||
install:
|
||||
@echo "Installing dependencies..."
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
. $(VENV)/bin/activate && $(PIP) install -r $(REQUIREMENTS)
|
||||
|
||||
# Initialize database
|
||||
init:
|
||||
@echo "Initializing database..."
|
||||
. $(VENV)/bin/activate && $(PYTHON) -c "from app.models.database import create_tables; create_tables(); print('Database initialized')"
|
||||
|
||||
# Build (for Python, this means installing dependencies)
|
||||
build: install
|
||||
@echo "Python application ready"
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
@echo "Cleaning up..."
|
||||
@rm -rf $(VENV)
|
||||
@rm -f *.db
|
||||
@rm -rf logs/
|
||||
@rm -f *.pid
|
||||
|
||||
# Run tests
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
. $(VENV)/bin/activate && $(PYTHON) test_enhanced_features.py
|
||||
|
||||
# Run linter
|
||||
lint:
|
||||
@echo "Running linter..."
|
||||
. $(VENV)/bin/activate && flake8 app/ --max-line-length=120 --ignore=E501,W503
|
||||
|
||||
# Build Docker image
|
||||
docker-build:
|
||||
@echo "Building Docker image $(APP_NAME):$(VERSION)..."
|
||||
docker build -t $(APP_NAME):$(VERSION) .
|
||||
docker tag $(APP_NAME):$(VERSION) $(APP_NAME):latest
|
||||
|
||||
# Push Docker image to registry
|
||||
docker-push: docker-build
|
||||
@echo "Pushing Docker image $(APP_NAME):$(VERSION)..."
|
||||
docker push $(APP_NAME):$(VERSION)
|
||||
docker push $(APP_NAME):latest
|
||||
|
||||
# Run locally
|
||||
run: build init
|
||||
@echo "Starting $(APP_NAME)..."
|
||||
. $(VENV)/bin/activate && $(PYTHON) -m uvicorn app.main_enhanced:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# Run in background
|
||||
start: build init
|
||||
@echo "Starting $(APP_NAME) in background..."
|
||||
. $(VENV)/bin/activate && nohup $(PYTHON) -m uvicorn app.main_enhanced:app --host 0.0.0.0 --port 8000 > logs/service.log 2>&1 &
|
||||
@echo $$! > service.pid
|
||||
@echo "Service started with PID $$(cat service.pid)"
|
||||
|
||||
# Stop service
|
||||
stop:
|
||||
@if [ -f service.pid ]; then \
|
||||
echo "Stopping $(APP_NAME)..."; \
|
||||
kill $$(cat service.pid) 2>/dev/null || true; \
|
||||
rm -f service.pid; \
|
||||
echo "Service stopped"; \
|
||||
else \
|
||||
echo "No service.pid found"; \
|
||||
fi
|
||||
|
||||
# Restart service
|
||||
restart: stop start
|
||||
|
||||
# Show service status
|
||||
status:
|
||||
@if [ -f service.pid ]; then \
|
||||
PID=$$(cat service.pid); \
|
||||
if ps -p $$PID > /dev/null 2>&1; then \
|
||||
echo "✅ $(APP_NAME) is running (PID: $$PID)"; \
|
||||
echo "📝 Log file: logs/service.log"; \
|
||||
echo "🌐 Access: http://localhost:8000"; \
|
||||
else \
|
||||
echo "❌ $(APP_NAME) is not running (PID file exists but process not found)"; \
|
||||
rm -f service.pid; \
|
||||
fi; \
|
||||
else \
|
||||
echo "❌ $(APP_NAME) is not running"; \
|
||||
fi
|
||||
|
||||
# Show logs
|
||||
logs:
|
||||
@if [ -f logs/service.log ]; then \
|
||||
echo "📝 Latest logs (last 50 lines):"; \
|
||||
echo "----------------------------------------"; \
|
||||
tail -n 50 logs/service.log; \
|
||||
echo "----------------------------------------"; \
|
||||
else \
|
||||
echo "❌ No log file found"; \
|
||||
fi
|
||||
|
||||
# Follow logs
|
||||
follow:
|
||||
@if [ -f logs/service.log ]; then \
|
||||
echo "📝 Following logs (Ctrl+C to exit):"; \
|
||||
tail -f logs/service.log; \
|
||||
else \
|
||||
echo "❌ No log file found"; \
|
||||
fi
|
||||
|
||||
# Show help
|
||||
help:
|
||||
@echo "Gitea Webhook Ambassador (Python) - Makefile commands:"
|
||||
@echo " install - Install Python dependencies"
|
||||
@echo " init - Initialize database"
|
||||
@echo " build - Install dependencies (alias for install)"
|
||||
@echo " clean - Remove build artifacts and logs"
|
||||
@echo " test - Run tests"
|
||||
@echo " lint - Run linter"
|
||||
@echo " docker-build - Build Docker image"
|
||||
@echo " docker-push - Build and push Docker image to registry"
|
||||
@echo " run - Install, init and run locally (foreground)"
|
||||
@echo " start - Install, init and start in background"
|
||||
@echo " stop - Stop background service"
|
||||
@echo " restart - Restart background service"
|
||||
@echo " status - Show service status"
|
||||
@echo " logs - Show latest logs"
|
||||
@echo " follow - Follow logs in real-time"
|
||||
@echo " help - Show this help message"
|
||||
189
apps/gitea-webhook-ambassador-python/README.md
Normal file
189
apps/gitea-webhook-ambassador-python/README.md
Normal file
@ -0,0 +1,189 @@
|
||||
# Gitea Webhook Ambassador (Python)
|
||||
|
||||
一个高性能的 Python webhook 服务,用于连接 Gitea 和 Jenkins,支持智能分发、高并发处理和防抖策略。
|
||||
|
||||
## 🚀 新特性
|
||||
|
||||
### 1. 智能分发策略
|
||||
- **dev 分支** → 触发 alpha 环境构建
|
||||
- **prod 分支** → 触发生产环境构建
|
||||
- **其他分支** → 可配置的默认策略
|
||||
|
||||
### 2. 高并发处理
|
||||
- **异步任务队列**: 使用 Celery + Redis 处理高并发
|
||||
- **任务排队机制**: 防止构建丢失,确保任务按序执行
|
||||
- **负载均衡**: 支持多 worker 实例
|
||||
|
||||
### 3. 防抖策略
|
||||
- **基于 commit hash + 分支的去重**: 防止重复触发
|
||||
- **时间窗口防抖**: 在指定时间窗口内的相同提交只触发一次
|
||||
- **智能去重**: 支持配置去重策略
|
||||
|
||||
## 🏗️ 架构设计
|
||||
|
||||
```
|
||||
Gitea Webhook → FastAPI → Celery Queue → Jenkins Workers
|
||||
↓ ↓ ↓ ↓
|
||||
验证签名 路由分发 任务排队 并发执行
|
||||
↓ ↓ ↓ ↓
|
||||
防抖检查 环境判断 持久化存储 状态反馈
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
gitea-webhook-ambassador-python/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # FastAPI 应用入口
|
||||
│ ├── config.py # 配置管理
|
||||
│ ├── models/ # 数据模型
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── gitea.py # Gitea webhook 模型
|
||||
│ │ └── jenkins.py # Jenkins 任务模型
|
||||
│ ├── services/ # 业务逻辑
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── webhook_service.py # Webhook 处理服务
|
||||
│ │ ├── jenkins_service.py # Jenkins 集成服务
|
||||
│ │ ├── queue_service.py # 队列管理服务
|
||||
│ │ └── dedup_service.py # 防抖服务
|
||||
│ ├── api/ # API 路由
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── webhook.py # Webhook 端点
|
||||
│ │ ├── health.py # 健康检查
|
||||
│ │ └── admin.py # 管理接口
|
||||
│ ├── core/ # 核心组件
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── security.py # 安全验证
|
||||
│ │ ├── database.py # 数据库连接
|
||||
│ │ └── cache.py # 缓存管理
|
||||
│ └── tasks/ # Celery 任务
|
||||
│ ├── __init__.py
|
||||
│ └── jenkins_tasks.py # Jenkins 任务处理
|
||||
├── tests/ # 测试文件
|
||||
├── docker/ # Docker 配置
|
||||
├── requirements.txt # Python 依赖
|
||||
├── docker-compose.yml # 开发环境
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
- **Web 框架**: FastAPI
|
||||
- **任务队列**: Celery + Redis
|
||||
- **数据库**: PostgreSQL (生产) / SQLite (开发)
|
||||
- **缓存**: Redis
|
||||
- **监控**: Prometheus + Grafana
|
||||
- **日志**: Structured logging with JSON
|
||||
- **测试**: pytest + pytest-asyncio
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. 配置环境
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件配置 Jenkins 和数据库连接
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
```bash
|
||||
# 启动 Redis
|
||||
docker run -d -p 6379:6379 redis:alpine
|
||||
|
||||
# 启动 Celery worker
|
||||
celery -A app.tasks worker --loglevel=info
|
||||
|
||||
# 启动 FastAPI 应用
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## 📋 配置说明
|
||||
|
||||
### 环境分发配置
|
||||
```yaml
|
||||
environments:
|
||||
dev:
|
||||
branches: ["dev", "develop", "development"]
|
||||
jenkins_job: "alpha-build"
|
||||
jenkins_url: "https://jenkins-alpha.example.com"
|
||||
prod:
|
||||
branches: ["prod", "production", "main", "master"]
|
||||
jenkins_job: "production-build"
|
||||
jenkins_url: "https://jenkins-prod.example.com"
|
||||
default:
|
||||
jenkins_job: "default-build"
|
||||
jenkins_url: "https://jenkins-default.example.com"
|
||||
```
|
||||
|
||||
### 防抖配置
|
||||
```yaml
|
||||
deduplication:
|
||||
enabled: true
|
||||
window_seconds: 300 # 5分钟防抖窗口
|
||||
strategy: "commit_branch" # commit_hash + branch
|
||||
cache_ttl: 3600 # 缓存1小时
|
||||
```
|
||||
|
||||
### 队列配置
|
||||
```yaml
|
||||
queue:
|
||||
max_concurrent: 10
|
||||
max_retries: 3
|
||||
retry_delay: 60 # 秒
|
||||
priority_levels: 3
|
||||
```
|
||||
|
||||
## 🔧 API 接口
|
||||
|
||||
### Webhook 端点
|
||||
```
|
||||
POST /webhook/gitea
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
```
|
||||
GET /health
|
||||
GET /health/queue
|
||||
GET /health/jenkins
|
||||
```
|
||||
|
||||
### 管理接口
|
||||
```
|
||||
GET /admin/queue/status
|
||||
GET /admin/queue/stats
|
||||
POST /admin/queue/clear
|
||||
```
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
pytest
|
||||
|
||||
# 运行特定测试
|
||||
pytest tests/test_webhook_service.py
|
||||
|
||||
# 运行性能测试
|
||||
pytest tests/test_performance.py
|
||||
```
|
||||
|
||||
## 📊 监控指标
|
||||
|
||||
- Webhook 接收率
|
||||
- 任务队列长度
|
||||
- Jenkins 构建成功率
|
||||
- 响应时间分布
|
||||
- 防抖命中率
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
- Webhook 签名验证
|
||||
- API 密钥认证
|
||||
- 请求频率限制
|
||||
- 输入验证和清理
|
||||
- 安全日志记录
|
||||
339
apps/gitea-webhook-ambassador-python/README_ENHANCED.md
Normal file
339
apps/gitea-webhook-ambassador-python/README_ENHANCED.md
Normal file
@ -0,0 +1,339 @@
|
||||
# Gitea Webhook Ambassador (Python Enhanced Version)
|
||||
|
||||
这是一个用 Python 重写的 Gitea Webhook Ambassador 服务,提供与 Go 版本相同的功能,但增加了 Web 界面和更多管理功能。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 方式一:使用 devbox 脚本(推荐,与 Go 版本一致)
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
./devbox install
|
||||
|
||||
# 初始化数据库
|
||||
./devbox init
|
||||
|
||||
# 启动服务
|
||||
./devbox start
|
||||
|
||||
# 查看状态
|
||||
./devbox status
|
||||
|
||||
# 查看日志
|
||||
./devbox logs
|
||||
|
||||
# 停止服务
|
||||
./devbox stop
|
||||
```
|
||||
|
||||
### 方式二:使用 Makefile
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
make install
|
||||
|
||||
# 初始化数据库
|
||||
make init
|
||||
|
||||
# 启动服务(前台运行)
|
||||
make run
|
||||
|
||||
# 启动服务(后台运行)
|
||||
make start
|
||||
|
||||
# 查看状态
|
||||
make status
|
||||
|
||||
# 查看日志
|
||||
make logs
|
||||
|
||||
# 停止服务
|
||||
make stop
|
||||
```
|
||||
|
||||
### 方式三:直接使用 Python
|
||||
|
||||
```bash
|
||||
# 创建虚拟环境
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 初始化数据库
|
||||
python -c "from app.models.database import create_tables; create_tables()"
|
||||
|
||||
# 启动服务
|
||||
python -m uvicorn app.main_enhanced:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
## 📁 目录结构(与 Go 版本一致)
|
||||
|
||||
```
|
||||
gitea-webhook-ambassador-python/
|
||||
├── app/ # 应用代码
|
||||
│ ├── auth/ # 认证模块
|
||||
│ ├── handlers/ # API 处理器
|
||||
│ ├── models/ # 数据模型
|
||||
│ ├── templates/ # HTML 模板
|
||||
│ ├── static/ # 静态文件
|
||||
│ └── main_enhanced.py # 主应用入口
|
||||
├── cmd/ # 命令行工具(与 Go 版本一致)
|
||||
│ └── server/ # 服务器启动
|
||||
├── configs/ # 配置文件(与 Go 版本一致)
|
||||
│ └── config.yaml # 主配置文件
|
||||
├── data/ # 数据目录(与 Go 版本一致)
|
||||
│ └── *.db # SQLite 数据库文件
|
||||
├── logs/ # 日志目录(与 Go 版本一致)
|
||||
│ └── service.log # 服务日志
|
||||
├── devbox # 启动脚本(与 Go 版本一致)
|
||||
├── Makefile # 构建脚本(与 Go 版本一致)
|
||||
├── requirements.txt # Python 依赖
|
||||
└── README_ENHANCED.md # 本文档
|
||||
```
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
编辑 `configs/config.yaml` 文件:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8000
|
||||
webhookPath: "/webhook"
|
||||
secretHeader: "X-Gitea-Signature"
|
||||
secretKey: "admin-secret-key-change-in-production"
|
||||
|
||||
jenkins:
|
||||
url: "http://jenkins.example.com"
|
||||
username: "jenkins-user"
|
||||
token: "jenkins-api-token"
|
||||
timeout: 30
|
||||
|
||||
admin:
|
||||
token: "admin-api-token"
|
||||
|
||||
database:
|
||||
path: "data/gitea-webhook-ambassador.db"
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
format: "text"
|
||||
file: "logs/service.log"
|
||||
|
||||
worker:
|
||||
poolSize: 10
|
||||
queueSize: 100
|
||||
maxRetries: 3
|
||||
retryBackoff: 1
|
||||
|
||||
eventCleanup:
|
||||
interval: 3600
|
||||
expireAfter: 7200
|
||||
```
|
||||
|
||||
## 🌐 Web 界面
|
||||
|
||||
启动服务后,访问以下地址:
|
||||
|
||||
- **登录页面**: http://localhost:8000
|
||||
- **仪表板**: http://localhost:8000/dashboard
|
||||
- **API 文档**: http://localhost:8000/docs
|
||||
|
||||
### 默认登录凭据
|
||||
- **用户名**: admin
|
||||
- **密码**: admin-secret-key-change-in-production
|
||||
|
||||
## 📊 功能特性
|
||||
|
||||
### ✅ 与 Go 版本相同的功能
|
||||
- Gitea Webhook 接收和处理
|
||||
- Jenkins 任务触发
|
||||
- 项目映射配置
|
||||
- 分支模式匹配
|
||||
- 重试机制
|
||||
- 日志记录
|
||||
|
||||
### 🆕 Python 版本增强功能
|
||||
- **Web 登录界面**: 基于 Bootstrap 5 的现代化界面
|
||||
- **数据库存储**: SQLite 数据库存储 API 密钥和配置
|
||||
- **JWT 认证**: 7 天有效期的 JWT 令牌
|
||||
- **前端仪表板**: 多标签页管理界面
|
||||
- **自动重定向**: 未认证用户自动跳转到登录页
|
||||
- **健康检查**: 服务状态监控
|
||||
- **统计信息**: 请求统计和性能指标
|
||||
|
||||
## 🔌 API 端点
|
||||
|
||||
### 认证相关
|
||||
- `POST /api/auth/login` - 用户登录
|
||||
- `GET /api/auth/verify` - 验证 JWT 令牌
|
||||
|
||||
### 项目管理
|
||||
- `GET /api/projects` - 获取项目列表
|
||||
- `POST /api/projects` - 创建新项目
|
||||
- `PUT /api/projects/{id}` - 更新项目
|
||||
- `DELETE /api/projects/{id}` - 删除项目
|
||||
|
||||
### API 密钥管理
|
||||
- `GET /api/keys` - 获取 API 密钥列表
|
||||
- `POST /api/keys` - 创建新 API 密钥
|
||||
- `DELETE /api/keys/{id}` - 删除 API 密钥
|
||||
|
||||
### 系统监控
|
||||
- `GET /api/health` - 健康检查
|
||||
- `GET /api/stats` - 统计信息
|
||||
- `GET /api/logs` - 日志查看
|
||||
|
||||
### Webhook 处理
|
||||
- `POST /webhook` - Gitea Webhook 接收端点
|
||||
|
||||
## 🛠️ 开发
|
||||
|
||||
### 运行测试
|
||||
```bash
|
||||
# 使用 devbox
|
||||
./devbox test
|
||||
|
||||
# 使用 Makefile
|
||||
make test
|
||||
|
||||
# 直接运行
|
||||
python test_enhanced_features.py
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
```bash
|
||||
# 使用 Makefile
|
||||
make lint
|
||||
|
||||
# 直接运行
|
||||
flake8 app/ --max-line-length=120 --ignore=E501,W503
|
||||
```
|
||||
|
||||
### 清理
|
||||
```bash
|
||||
# 使用 devbox
|
||||
./devbox clean
|
||||
|
||||
# 使用 Makefile
|
||||
make clean
|
||||
```
|
||||
|
||||
## 🐳 Docker 部署
|
||||
|
||||
### 构建镜像
|
||||
```bash
|
||||
# 使用 Makefile
|
||||
make docker-build
|
||||
|
||||
# 直接构建
|
||||
docker build -t gitea-webhook-ambassador:latest .
|
||||
```
|
||||
|
||||
### 运行容器
|
||||
```bash
|
||||
docker run -d \
|
||||
--name gitea-webhook-ambassador \
|
||||
-p 8000:8000 \
|
||||
-v $(pwd)/configs:/app/configs \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
gitea-webhook-ambassador:latest
|
||||
```
|
||||
|
||||
## 📈 与 Go 版本对比
|
||||
|
||||
| 特性 | Go 版本 | Python 版本 |
|
||||
|------|---------|-------------|
|
||||
| **启动方式** | `./devbox start` | `./devbox start` |
|
||||
| **目录结构** | 标准 Go 项目结构 | 与 Go 版本一致 |
|
||||
| **配置文件** | `configs/config.yaml` | `configs/config.yaml` |
|
||||
| **日志目录** | `logs/` | `logs/` |
|
||||
| **数据目录** | `data/` | `data/` |
|
||||
| **Web 界面** | ❌ 无 | ✅ 完整仪表板 |
|
||||
| **数据库** | ❌ 无 | ✅ SQLite |
|
||||
| **JWT 认证** | ❌ 无 | ✅ 7天有效期 |
|
||||
| **API 密钥管理** | ❌ 无 | ✅ 数据库存储 |
|
||||
| **健康检查** | ✅ 基础 | ✅ 增强版 |
|
||||
| **性能** | 🚀 极高 | 🚀 高 |
|
||||
|
||||
## 🔄 迁移指南
|
||||
|
||||
### 从 Go 版本迁移到 Python 版本
|
||||
|
||||
1. **停止 Go 服务**
|
||||
```bash
|
||||
cd /path/to/go-version
|
||||
./devbox stop
|
||||
```
|
||||
|
||||
2. **启动 Python 服务**
|
||||
```bash
|
||||
cd /path/to/python-version
|
||||
./devbox install
|
||||
./devbox init
|
||||
./devbox start
|
||||
```
|
||||
|
||||
3. **验证服务**
|
||||
```bash
|
||||
./devbox status
|
||||
curl http://localhost:8000/api/health
|
||||
```
|
||||
|
||||
4. **配置 Webhook**
|
||||
- 更新 Gitea Webhook URL 为新的 Python 服务地址
|
||||
- 确保 Jenkins 配置正确
|
||||
|
||||
## 🆘 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
**1. 端口被占用**
|
||||
```bash
|
||||
# 检查端口占用
|
||||
lsof -i :8000
|
||||
|
||||
# 停止占用进程
|
||||
sudo kill -9 <PID>
|
||||
```
|
||||
|
||||
**2. 虚拟环境问题**
|
||||
```bash
|
||||
# 重新创建虚拟环境
|
||||
rm -rf venv
|
||||
./devbox install
|
||||
```
|
||||
|
||||
**3. 数据库问题**
|
||||
```bash
|
||||
# 重新初始化数据库
|
||||
./devbox init
|
||||
```
|
||||
|
||||
**4. 权限问题**
|
||||
```bash
|
||||
# 设置脚本权限
|
||||
chmod +x devbox
|
||||
```
|
||||
|
||||
### 日志查看
|
||||
```bash
|
||||
# 查看实时日志
|
||||
./devbox follow
|
||||
|
||||
# 查看最新日志
|
||||
./devbox logs
|
||||
|
||||
# 查看完整日志
|
||||
tail -f logs/service.log
|
||||
```
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如有问题,请检查:
|
||||
1. 服务状态:`./devbox status`
|
||||
2. 日志信息:`./devbox logs`
|
||||
3. 配置文件:`configs/config.yaml`
|
||||
4. 网络连接:`curl http://localhost:8000/api/health`
|
||||
379
apps/gitea-webhook-ambassador-python/USAGE.md
Normal file
379
apps/gitea-webhook-ambassador-python/USAGE.md
Normal file
@ -0,0 +1,379 @@
|
||||
# 🚀 Gitea Webhook Ambassador 使用指南
|
||||
|
||||
## 📋 目录
|
||||
1. [快速开始](#快速开始)
|
||||
2. [配置说明](#配置说明)
|
||||
3. [API 接口](#api-接口)
|
||||
4. [数据库管理](#数据库管理)
|
||||
5. [监控和日志](#监控和日志)
|
||||
6. [故障排除](#故障排除)
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 环境准备
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
cd freeleaps-ops/apps/gitea-webhook-ambassador-python
|
||||
|
||||
# 运行快速设置脚本
|
||||
chmod +x scripts/setup.sh
|
||||
./scripts/setup.sh
|
||||
```
|
||||
|
||||
### 2. 配置环境
|
||||
|
||||
编辑 `.env` 文件,配置必要的参数:
|
||||
|
||||
```bash
|
||||
# 编辑配置文件
|
||||
nano .env
|
||||
```
|
||||
|
||||
**必需配置**:
|
||||
```env
|
||||
# Jenkins 配置
|
||||
JENKINS_USERNAME=your_jenkins_username
|
||||
JENKINS_TOKEN=your_jenkins_api_token
|
||||
|
||||
# 安全配置
|
||||
SECURITY_SECRET_KEY=your-secret-key-here-make-it-long-and-random
|
||||
```
|
||||
|
||||
### 3. 启动服务
|
||||
|
||||
```bash
|
||||
# 方法1: 使用启动脚本
|
||||
chmod +x scripts/start.sh
|
||||
./scripts/start.sh
|
||||
|
||||
# 方法2: 手动启动
|
||||
# 启动 Redis
|
||||
docker run -d --name webhook-redis -p 6379:6379 redis:alpine
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 启动 API 服务
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
|
||||
# 新终端启动 Celery worker
|
||||
celery -A app.tasks.jenkins_tasks worker --loglevel=info --concurrency=4
|
||||
|
||||
# 新终端启动定时任务
|
||||
celery -A app.tasks.jenkins_tasks beat --loglevel=info
|
||||
```
|
||||
|
||||
### 4. 验证安装
|
||||
|
||||
访问以下地址验证服务是否正常:
|
||||
|
||||
- **API 文档**: http://localhost:8000/docs
|
||||
- **健康检查**: http://localhost:8000/health
|
||||
- **监控指标**: http://localhost:8000/metrics
|
||||
|
||||
## ⚙️ 配置说明
|
||||
|
||||
### 环境分发配置
|
||||
|
||||
编辑 `config/environments.yaml` 文件:
|
||||
|
||||
```yaml
|
||||
environments:
|
||||
dev:
|
||||
branches: ["dev", "develop", "development", "feature/*"]
|
||||
jenkins_job: "alpha-build"
|
||||
jenkins_url: "https://jenkins-alpha.freeleaps.com"
|
||||
priority: 2
|
||||
|
||||
prod:
|
||||
branches: ["prod", "production", "main", "master", "release/*"]
|
||||
jenkins_job: "production-build"
|
||||
jenkins_url: "https://jenkins-prod.freeleaps.com"
|
||||
priority: 1
|
||||
```
|
||||
|
||||
### 防抖配置
|
||||
|
||||
```yaml
|
||||
deduplication:
|
||||
enabled: true
|
||||
window_seconds: 300 # 5分钟防抖窗口
|
||||
strategy: "commit_branch" # commit_hash + branch
|
||||
cache_ttl: 3600 # 缓存1小时
|
||||
```
|
||||
|
||||
## 🔧 API 接口
|
||||
|
||||
### Webhook 端点
|
||||
|
||||
**POST** `/webhook/gitea`
|
||||
|
||||
接收 Gitea webhook 事件:
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/webhook/gitea" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Gitea-Signature: your-secret-key" \
|
||||
-d '{
|
||||
"ref": "refs/heads/dev",
|
||||
"before": "abc123",
|
||||
"after": "def456",
|
||||
"repository": {
|
||||
"full_name": "freeleaps/my-project",
|
||||
"clone_url": "https://gitea.freeleaps.com/freeleaps/my-project.git"
|
||||
},
|
||||
"pusher": {
|
||||
"login": "developer",
|
||||
"email": "dev@freeleaps.com"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 健康检查
|
||||
|
||||
**GET** `/health`
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": 1640995200.0,
|
||||
"services": {
|
||||
"redis": "healthy",
|
||||
"celery": "healthy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 队列状态
|
||||
|
||||
**GET** `/health/queue`
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/health/queue
|
||||
```
|
||||
|
||||
响应示例:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"queue_stats": {
|
||||
"active_tasks": 2,
|
||||
"queued_tasks": 5,
|
||||
"worker_count": 4,
|
||||
"total_queue_length": 7
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 监控指标
|
||||
|
||||
**GET** `/metrics`
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/metrics
|
||||
```
|
||||
|
||||
返回 Prometheus 格式的监控指标。
|
||||
|
||||
## 🗄️ 数据库管理
|
||||
|
||||
### 创建项目映射
|
||||
|
||||
使用 Python 脚本创建项目映射:
|
||||
|
||||
```python
|
||||
# create_mapping.py
|
||||
import asyncio
|
||||
from app.services.database_service import get_database_service
|
||||
|
||||
async def create_mapping():
|
||||
db_service = get_database_service()
|
||||
|
||||
mapping_data = {
|
||||
"repository_name": "freeleaps/my-project",
|
||||
"default_job": "default-build",
|
||||
"branch_jobs": [
|
||||
{"branch_name": "dev", "job_name": "alpha-build"},
|
||||
{"branch_name": "main", "job_name": "production-build"}
|
||||
],
|
||||
"branch_patterns": [
|
||||
{"pattern": r"feature/.*", "job_name": "feature-build"},
|
||||
{"pattern": r"hotfix/.*", "job_name": "hotfix-build"}
|
||||
]
|
||||
}
|
||||
|
||||
success = await db_service.create_project_mapping(mapping_data)
|
||||
print(f"创建映射: {'成功' if success else '失败'}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(create_mapping())
|
||||
```
|
||||
|
||||
运行脚本:
|
||||
```bash
|
||||
python create_mapping.py
|
||||
```
|
||||
|
||||
### 查看触发日志
|
||||
|
||||
```python
|
||||
# view_logs.py
|
||||
import asyncio
|
||||
from app.services.database_service import get_database_service
|
||||
|
||||
async def view_logs():
|
||||
db_service = get_database_service()
|
||||
|
||||
logs = await db_service.get_trigger_logs(
|
||||
repository_name="freeleaps/my-project",
|
||||
limit=10
|
||||
)
|
||||
|
||||
for log in logs:
|
||||
print(f"[{log['created_at']}] {log['repository_name']} - {log['branch_name']} - {log['status']}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(view_logs())
|
||||
```
|
||||
|
||||
## 📊 监控和日志
|
||||
|
||||
### 日志查看
|
||||
|
||||
```bash
|
||||
# 查看应用日志
|
||||
tail -f logs/app.log
|
||||
|
||||
# 查看 Celery 日志
|
||||
tail -f logs/celery.log
|
||||
```
|
||||
|
||||
### 监控面板
|
||||
|
||||
使用 Grafana 创建监控面板:
|
||||
|
||||
1. 访问 http://localhost:3000 (Grafana)
|
||||
2. 用户名: `admin`, 密码: `admin`
|
||||
3. 添加 Prometheus 数据源: http://prometheus:9090
|
||||
4. 导入监控面板
|
||||
|
||||
### 关键指标
|
||||
|
||||
- **webhook_requests_total**: Webhook 请求总数
|
||||
- **webhook_request_duration_seconds**: 请求响应时间
|
||||
- **queue_size**: 队列长度
|
||||
- **dedup_hits_total**: 防抖命中次数
|
||||
|
||||
## 🔧 故障排除
|
||||
|
||||
### 常见问题
|
||||
|
||||
#### 1. Redis 连接失败
|
||||
|
||||
```bash
|
||||
# 检查 Redis 状态
|
||||
docker ps | grep redis
|
||||
|
||||
# 重启 Redis
|
||||
docker restart webhook-redis
|
||||
```
|
||||
|
||||
#### 2. Celery Worker 无法启动
|
||||
|
||||
```bash
|
||||
# 检查 Celery 配置
|
||||
celery -A app.tasks.jenkins_tasks inspect active
|
||||
|
||||
# 重启 Worker
|
||||
pkill -f "celery.*worker"
|
||||
celery -A app.tasks.jenkins_tasks worker --loglevel=info
|
||||
```
|
||||
|
||||
#### 3. Jenkins 连接失败
|
||||
|
||||
```bash
|
||||
# 测试 Jenkins 连接
|
||||
curl -u username:token https://jenkins.example.com/api/json
|
||||
```
|
||||
|
||||
#### 4. 数据库错误
|
||||
|
||||
```bash
|
||||
# 检查数据库文件
|
||||
ls -la webhook_ambassador.db
|
||||
|
||||
# 重新初始化数据库
|
||||
rm webhook_ambassador.db
|
||||
python -c "from app.services.database_service import get_database_service; get_database_service()"
|
||||
```
|
||||
|
||||
### 日志级别调整
|
||||
|
||||
编辑 `.env` 文件:
|
||||
|
||||
```env
|
||||
LOGGING_LEVEL=DEBUG # 开发环境
|
||||
LOGGING_LEVEL=INFO # 生产环境
|
||||
```
|
||||
|
||||
### 性能调优
|
||||
|
||||
#### 增加并发处理能力
|
||||
|
||||
```env
|
||||
QUEUE_MAX_CONCURRENT=20
|
||||
```
|
||||
|
||||
#### 调整防抖窗口
|
||||
|
||||
```env
|
||||
DEDUPLICATION_WINDOW_SECONDS=600 # 10分钟
|
||||
```
|
||||
|
||||
## 🐳 Docker 部署
|
||||
|
||||
### 使用 Docker Compose
|
||||
|
||||
```bash
|
||||
# 启动所有服务
|
||||
docker-compose up -d
|
||||
|
||||
# 查看服务状态
|
||||
docker-compose ps
|
||||
|
||||
# 查看日志
|
||||
docker-compose logs -f api
|
||||
```
|
||||
|
||||
### 生产环境部署
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t webhook-ambassador:latest .
|
||||
|
||||
# 运行容器
|
||||
docker run -d \
|
||||
--name webhook-ambassador \
|
||||
-p 8000:8000 \
|
||||
-v $(pwd)/config:/app/config \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
--env-file .env \
|
||||
webhook-ambassador:latest
|
||||
```
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如果遇到问题,请检查:
|
||||
|
||||
1. 日志文件中的错误信息
|
||||
2. 健康检查端点返回的状态
|
||||
3. 监控指标中的异常数据
|
||||
4. 网络连接和防火墙设置
|
||||
|
||||
更多帮助请参考项目文档或提交 Issue。
|
||||
93
apps/gitea-webhook-ambassador-python/app/auth/middleware.py
Normal file
93
apps/gitea-webhook-ambassador-python/app/auth/middleware.py
Normal file
@ -0,0 +1,93 @@
|
||||
from fastapi import HTTPException, Depends, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
import jwt
|
||||
import secrets
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from ..models.database import get_db, APIKey
|
||||
from ..config import settings
|
||||
|
||||
# JWT 配置
|
||||
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "your-secret-key-change-in-production")
|
||||
JWT_ALGORITHM = "HS256"
|
||||
JWT_EXPIRATION_HOURS = 24 * 7 # 7 天有效期
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
class AuthMiddleware:
|
||||
def __init__(self):
|
||||
self.secret_key = JWT_SECRET_KEY
|
||||
|
||||
def create_access_token(self, data: dict, expires_delta: Optional[timedelta] = None):
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS)
|
||||
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, self.secret_key, algorithm=JWT_ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(self, token: str):
|
||||
try:
|
||||
payload = jwt.decode(token, self.secret_key, algorithms=[JWT_ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token has expired"
|
||||
)
|
||||
except jwt.JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token"
|
||||
)
|
||||
|
||||
def verify_api_key(self, api_key: str, db: Session):
|
||||
"""验证 API 密钥"""
|
||||
db_key = db.query(APIKey).filter(APIKey.key == api_key).first()
|
||||
return db_key is not None
|
||||
|
||||
def generate_api_key(self) -> str:
|
||||
"""生成新的 API 密钥"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
# 创建认证中间件实例
|
||||
auth_middleware = AuthMiddleware()
|
||||
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
||||
"""获取当前用户(JWT 认证)"""
|
||||
token = credentials.credentials
|
||||
payload = auth_middleware.verify_token(token)
|
||||
return payload
|
||||
|
||||
async def get_current_user_api_key(api_key: str = Depends(security), db: Session = Depends(get_db)):
|
||||
"""获取当前用户(API 密钥认证)"""
|
||||
if not auth_middleware.verify_api_key(api_key.credentials, db):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid API key"
|
||||
)
|
||||
return {"api_key": api_key.credentials}
|
||||
|
||||
def require_auth(use_api_key: bool = False):
|
||||
"""认证依赖装饰器"""
|
||||
if use_api_key:
|
||||
return get_current_user_api_key
|
||||
else:
|
||||
return get_current_user
|
||||
|
||||
def handle_auth_error(request, exc):
|
||||
"""处理认证错误"""
|
||||
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"error": "Invalid or expired token"}
|
||||
)
|
||||
else:
|
||||
return RedirectResponse(url="/login", status_code=303)
|
||||
189
apps/gitea-webhook-ambassador-python/app/config.py
Normal file
189
apps/gitea-webhook-ambassador-python/app/config.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""
|
||||
配置管理模块
|
||||
支持环境分发、防抖策略和队列配置
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import Field, validator
|
||||
from pydantic_settings import BaseSettings
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class EnvironmentConfig(BaseSettings):
|
||||
"""环境配置"""
|
||||
branches: List[str] = Field(default_factory=list)
|
||||
jenkins_job: str
|
||||
jenkins_url: str
|
||||
priority: int = Field(default=1, ge=1, le=10)
|
||||
|
||||
|
||||
class DeduplicationConfig(BaseSettings):
|
||||
"""防抖配置"""
|
||||
enabled: bool = True
|
||||
window_seconds: int = Field(default=300, ge=1) # 5分钟防抖窗口
|
||||
strategy: str = Field(default="commit_branch") # commit_hash + branch
|
||||
cache_ttl: int = Field(default=3600, ge=1) # 缓存1小时
|
||||
|
||||
|
||||
class QueueConfig(BaseSettings):
|
||||
"""队列配置"""
|
||||
max_concurrent: int = Field(default=10, ge=1)
|
||||
max_retries: int = Field(default=3, ge=0)
|
||||
retry_delay: int = Field(default=60, ge=1) # 秒
|
||||
priority_levels: int = Field(default=3, ge=1, le=10)
|
||||
|
||||
|
||||
class JenkinsConfig(BaseSettings):
|
||||
"""Jenkins 配置"""
|
||||
username: str
|
||||
token: str
|
||||
timeout: int = Field(default=30, ge=1)
|
||||
retry_attempts: int = Field(default=3, ge=1)
|
||||
|
||||
|
||||
class DatabaseConfig(BaseSettings):
|
||||
"""数据库配置"""
|
||||
url: str = Field(default="sqlite:///./webhook_ambassador.db")
|
||||
echo: bool = False
|
||||
pool_size: int = Field(default=10, ge=1)
|
||||
max_overflow: int = Field(default=20, ge=0)
|
||||
|
||||
|
||||
class RedisConfig(BaseSettings):
|
||||
"""Redis 配置"""
|
||||
url: str = Field(default="redis://localhost:6379/0")
|
||||
password: Optional[str] = None
|
||||
db: int = Field(default=0, ge=0)
|
||||
|
||||
|
||||
class LoggingConfig(BaseSettings):
|
||||
"""日志配置"""
|
||||
level: str = Field(default="INFO")
|
||||
format: str = Field(default="json")
|
||||
file: Optional[str] = None
|
||||
|
||||
|
||||
class SecurityConfig(BaseSettings):
|
||||
"""安全配置"""
|
||||
secret_key: str
|
||||
webhook_secret_header: str = Field(default="X-Gitea-Signature")
|
||||
rate_limit_per_minute: int = Field(default=100, ge=1)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""主配置类"""
|
||||
|
||||
# 基础配置
|
||||
app_name: str = "Gitea Webhook Ambassador"
|
||||
version: str = "1.0.0"
|
||||
debug: bool = False
|
||||
|
||||
# 服务器配置
|
||||
host: str = "0.0.0.0"
|
||||
port: int = Field(default=8000, ge=1, le=65535)
|
||||
|
||||
# 数据库配置
|
||||
database_url: str = Field(default="sqlite:///./webhook_ambassador.db")
|
||||
|
||||
# Redis 配置
|
||||
redis_url: str = Field(default="redis://localhost:6379/0")
|
||||
redis_password: str = Field(default="")
|
||||
redis_db: int = Field(default=0)
|
||||
|
||||
# Jenkins 配置
|
||||
jenkins_username: str = Field(default="admin")
|
||||
jenkins_token: str = Field(default="")
|
||||
jenkins_timeout: int = Field(default=30)
|
||||
|
||||
# 安全配置
|
||||
security_secret_key: str = Field(default="")
|
||||
security_webhook_secret_header: str = Field(default="X-Gitea-Signature")
|
||||
security_rate_limit_per_minute: int = Field(default=100)
|
||||
|
||||
# 日志配置
|
||||
logging_level: str = Field(default="INFO")
|
||||
logging_format: str = Field(default="json")
|
||||
logging_file: str = Field(default="")
|
||||
|
||||
# 队列配置
|
||||
queue_max_concurrent: int = Field(default=10)
|
||||
queue_max_retries: int = Field(default=3)
|
||||
queue_retry_delay: int = Field(default=60)
|
||||
queue_priority_levels: int = Field(default=3)
|
||||
|
||||
# 防抖配置
|
||||
deduplication_enabled: bool = Field(default=True)
|
||||
deduplication_window_seconds: int = Field(default=300)
|
||||
deduplication_strategy: str = Field(default="commit_branch")
|
||||
deduplication_cache_ttl: int = Field(default=3600)
|
||||
|
||||
# 业务配置
|
||||
environments: Dict[str, EnvironmentConfig] = Field(default_factory=dict)
|
||||
deduplication: DeduplicationConfig = DeduplicationConfig()
|
||||
queue: QueueConfig = QueueConfig()
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
env_nested_delimiter = "__"
|
||||
|
||||
@validator("environments", pre=True)
|
||||
def load_environments_from_file(cls, v):
|
||||
"""从配置文件加载环境配置"""
|
||||
if isinstance(v, dict) and v:
|
||||
return v
|
||||
|
||||
# 尝试从配置文件加载
|
||||
config_file = Path("config/environments.yaml")
|
||||
if config_file.exists():
|
||||
with open(config_file, "r", encoding="utf-8") as f:
|
||||
config_data = yaml.safe_load(f)
|
||||
return config_data.get("environments", {})
|
||||
|
||||
# 默认配置
|
||||
return {
|
||||
"dev": EnvironmentConfig(
|
||||
branches=["dev", "develop", "development"],
|
||||
jenkins_job="alpha-build",
|
||||
jenkins_url="https://jenkins-alpha.example.com",
|
||||
priority=2
|
||||
),
|
||||
"prod": EnvironmentConfig(
|
||||
branches=["prod", "production", "main", "master"],
|
||||
jenkins_job="production-build",
|
||||
jenkins_url="https://jenkins-prod.example.com",
|
||||
priority=1
|
||||
),
|
||||
"default": EnvironmentConfig(
|
||||
branches=["*"],
|
||||
jenkins_job="default-build",
|
||||
jenkins_url="https://jenkins-default.example.com",
|
||||
priority=3
|
||||
)
|
||||
}
|
||||
|
||||
def get_environment_for_branch(self, branch: str) -> Optional[EnvironmentConfig]:
|
||||
"""根据分支名获取对应的环境配置"""
|
||||
for env_name, env_config in self.environments.items():
|
||||
if branch in env_config.branches or "*" in env_config.branches:
|
||||
return env_config
|
||||
return None
|
||||
|
||||
def get_environment_by_name(self, name: str) -> Optional[EnvironmentConfig]:
|
||||
"""根据环境名获取配置"""
|
||||
return self.environments.get(name)
|
||||
|
||||
|
||||
# 全局配置实例
|
||||
settings = Settings()
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""获取配置实例"""
|
||||
return settings
|
||||
|
||||
|
||||
def reload_settings():
|
||||
"""重新加载配置"""
|
||||
global settings
|
||||
settings = Settings()
|
||||
@ -0,0 +1,10 @@
|
||||
"""
|
||||
Handlers 包
|
||||
包含所有 API 处理器
|
||||
"""
|
||||
|
||||
from . import webhook
|
||||
from . import health
|
||||
from . import admin
|
||||
|
||||
__all__ = ["webhook", "health", "admin"]
|
||||
287
apps/gitea-webhook-ambassador-python/app/handlers/admin.py
Normal file
287
apps/gitea-webhook-ambassador-python/app/handlers/admin.py
Normal file
@ -0,0 +1,287 @@
|
||||
"""
|
||||
管理 API 处理器
|
||||
提供项目映射和 API 密钥管理功能
|
||||
"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.api_key import APIKey
|
||||
from app.models.project_mapping import ProjectMapping
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
# API 密钥相关模型
|
||||
class APIKeyResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
key_prefix: str
|
||||
created_at: datetime
|
||||
last_used: datetime
|
||||
is_active: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class CreateAPIKeyRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
class CreateAPIKeyResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
key: str
|
||||
created_at: datetime
|
||||
|
||||
# 项目映射相关模型
|
||||
class ProjectMappingRequest(BaseModel):
|
||||
repository_name: str
|
||||
default_job: str
|
||||
branch_jobs: Optional[List[dict]] = []
|
||||
branch_patterns: Optional[List[dict]] = []
|
||||
|
||||
class ProjectMappingResponse(BaseModel):
|
||||
id: int
|
||||
repository_name: str
|
||||
default_job: str
|
||||
branch_jobs: List[dict]
|
||||
branch_patterns: List[dict]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# API 密钥管理端点
|
||||
@router.get("/api-keys", response_model=List[APIKeyResponse])
|
||||
async def list_api_keys(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""列出所有 API 密钥"""
|
||||
try:
|
||||
api_keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
|
||||
return api_keys
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list API keys: {str(e)}")
|
||||
|
||||
@router.post("/api-keys", response_model=CreateAPIKeyResponse)
|
||||
async def create_api_key(
|
||||
request: CreateAPIKeyRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""创建新的 API 密钥"""
|
||||
try:
|
||||
# 生成 API 密钥
|
||||
api_key = secrets.token_urlsafe(32)
|
||||
key_prefix = api_key[:8] # 显示前8位作为前缀
|
||||
|
||||
# 创建数据库记录
|
||||
db_api_key = APIKey(
|
||||
name=request.name,
|
||||
key_hash=api_key, # 实际应用中应该哈希存储
|
||||
key_prefix=key_prefix,
|
||||
created_at=datetime.utcnow(),
|
||||
last_used=datetime.utcnow(),
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(db_api_key)
|
||||
db.commit()
|
||||
db.refresh(db_api_key)
|
||||
|
||||
return CreateAPIKeyResponse(
|
||||
id=db_api_key.id,
|
||||
name=db_api_key.name,
|
||||
key=api_key, # 只在创建时返回完整密钥
|
||||
created_at=db_api_key.created_at
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create API key: {str(e)}")
|
||||
|
||||
@router.delete("/api-keys/{key_id}")
|
||||
async def delete_api_key(
|
||||
key_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""删除 API 密钥"""
|
||||
try:
|
||||
api_key = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
db.delete(api_key)
|
||||
db.commit()
|
||||
|
||||
return {"message": "API key deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete API key: {str(e)}")
|
||||
|
||||
@router.post("/api-keys/{key_id}/revoke")
|
||||
async def revoke_api_key(
|
||||
key_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""撤销 API 密钥"""
|
||||
try:
|
||||
api_key = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
api_key.is_active = False
|
||||
db.commit()
|
||||
|
||||
return {"message": "API key revoked successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to revoke API key: {str(e)}")
|
||||
|
||||
# 项目映射管理端点
|
||||
@router.get("/projects", response_model=List[ProjectMappingResponse])
|
||||
async def list_project_mappings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""列出所有项目映射"""
|
||||
try:
|
||||
mappings = db.query(ProjectMapping).order_by(ProjectMapping.created_at.desc()).all()
|
||||
return mappings
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list project mappings: {str(e)}")
|
||||
|
||||
@router.post("/projects", response_model=ProjectMappingResponse)
|
||||
async def create_project_mapping(
|
||||
request: ProjectMappingRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""创建项目映射"""
|
||||
try:
|
||||
# 检查是否已存在
|
||||
existing = db.query(ProjectMapping).filter(
|
||||
ProjectMapping.repository_name == request.repository_name
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Project mapping already exists")
|
||||
|
||||
# 创建新映射
|
||||
mapping = ProjectMapping(
|
||||
repository_name=request.repository_name,
|
||||
default_job=request.default_job,
|
||||
branch_jobs=request.branch_jobs or [],
|
||||
branch_patterns=request.branch_patterns or [],
|
||||
created_at=datetime.utcnow(),
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
db.add(mapping)
|
||||
db.commit()
|
||||
db.refresh(mapping)
|
||||
|
||||
return mapping
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create project mapping: {str(e)}")
|
||||
|
||||
@router.get("/projects/{repository_name}", response_model=ProjectMappingResponse)
|
||||
async def get_project_mapping(
|
||||
repository_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""获取项目映射"""
|
||||
try:
|
||||
mapping = db.query(ProjectMapping).filter(
|
||||
ProjectMapping.repository_name == repository_name
|
||||
).first()
|
||||
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="Project mapping not found")
|
||||
|
||||
return mapping
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get project mapping: {str(e)}")
|
||||
|
||||
@router.delete("/projects/{repository_name}")
|
||||
async def delete_project_mapping(
|
||||
repository_name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""删除项目映射"""
|
||||
try:
|
||||
mapping = db.query(ProjectMapping).filter(
|
||||
ProjectMapping.repository_name == repository_name
|
||||
).first()
|
||||
|
||||
if not mapping:
|
||||
raise HTTPException(status_code=404, detail="Project mapping not found")
|
||||
|
||||
db.delete(mapping)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Project mapping deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to delete project mapping: {str(e)}")
|
||||
|
||||
# 统计信息端点
|
||||
@router.get("/stats")
|
||||
async def get_admin_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""获取管理统计信息"""
|
||||
try:
|
||||
# API 密钥统计
|
||||
total_keys = db.query(APIKey).count()
|
||||
active_keys = db.query(APIKey).filter(APIKey.is_active == True).count()
|
||||
|
||||
# 最近使用的密钥
|
||||
recent_keys = db.query(APIKey).filter(
|
||||
APIKey.last_used >= datetime.utcnow() - timedelta(days=7)
|
||||
).count()
|
||||
|
||||
# 项目映射统计
|
||||
total_mappings = db.query(ProjectMapping).count()
|
||||
|
||||
return {
|
||||
"api_keys": {
|
||||
"total": total_keys,
|
||||
"active": active_keys,
|
||||
"recently_used": recent_keys
|
||||
},
|
||||
"project_mappings": {
|
||||
"total": total_mappings
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get admin stats: {str(e)}")
|
||||
122
apps/gitea-webhook-ambassador-python/app/handlers/auth.py
Normal file
122
apps/gitea-webhook-ambassador-python/app/handlers/auth.py
Normal file
@ -0,0 +1,122 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import os
|
||||
|
||||
from ..models.database import get_db, APIKey
|
||||
from ..auth.middleware import auth_middleware
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["authentication"])
|
||||
|
||||
# 请求/响应模型
|
||||
class LoginRequest(BaseModel):
|
||||
secret_key: str
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
|
||||
class APIKeyCreate(BaseModel):
|
||||
description: str
|
||||
|
||||
class APIKeyResponse(BaseModel):
|
||||
id: int
|
||||
key: str
|
||||
description: Optional[str]
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class APIKeyList(BaseModel):
|
||||
keys: List[APIKeyResponse]
|
||||
|
||||
# 获取管理员密钥
|
||||
def get_admin_secret_key():
|
||||
return os.getenv("ADMIN_SECRET_KEY", "admin-secret-key-change-in-production")
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(request: LoginRequest):
|
||||
"""管理员登录"""
|
||||
admin_key = get_admin_secret_key()
|
||||
|
||||
if request.secret_key != admin_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid secret key"
|
||||
)
|
||||
|
||||
# 生成 JWT 令牌
|
||||
token = auth_middleware.create_access_token(
|
||||
data={"sub": "admin", "role": "admin"}
|
||||
)
|
||||
|
||||
return LoginResponse(token=token)
|
||||
|
||||
@router.post("/keys", response_model=APIKeyResponse)
|
||||
async def create_api_key(
|
||||
request: APIKeyCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(auth_middleware.get_current_user)
|
||||
):
|
||||
"""创建新的 API 密钥"""
|
||||
# 生成新的 API 密钥
|
||||
api_key_value = auth_middleware.generate_api_key()
|
||||
|
||||
# 保存到数据库
|
||||
db_key = APIKey(
|
||||
key=api_key_value,
|
||||
description=request.description
|
||||
)
|
||||
|
||||
db.add(db_key)
|
||||
db.commit()
|
||||
db.refresh(db_key)
|
||||
|
||||
return APIKeyResponse(
|
||||
id=db_key.id,
|
||||
key=db_key.key,
|
||||
description=db_key.description,
|
||||
created_at=db_key.created_at.isoformat()
|
||||
)
|
||||
|
||||
@router.get("/keys", response_model=APIKeyList)
|
||||
async def list_api_keys(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(auth_middleware.get_current_user)
|
||||
):
|
||||
"""获取所有 API 密钥"""
|
||||
keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
|
||||
|
||||
return APIKeyList(
|
||||
keys=[
|
||||
APIKeyResponse(
|
||||
id=key.id,
|
||||
key=key.key,
|
||||
description=key.description,
|
||||
created_at=key.created_at.isoformat()
|
||||
)
|
||||
for key in keys
|
||||
]
|
||||
)
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_api_key(
|
||||
key_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(auth_middleware.get_current_user)
|
||||
):
|
||||
"""删除 API 密钥"""
|
||||
key = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
|
||||
if not key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
db.delete(key)
|
||||
db.commit()
|
||||
|
||||
return {"message": "API key deleted successfully"}
|
||||
145
apps/gitea-webhook-ambassador-python/app/handlers/health.py
Normal file
145
apps/gitea-webhook-ambassador-python/app/handlers/health.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""
|
||||
健康检查处理器
|
||||
提供服务健康状态检查
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.services.jenkins_service import get_jenkins_service
|
||||
from app.services.queue_service import get_queue_service
|
||||
from app.config import get_settings
|
||||
|
||||
router = APIRouter(prefix="/health", tags=["health"])
|
||||
|
||||
|
||||
class JenkinsStatus(BaseModel):
|
||||
status: str
|
||||
message: str = None
|
||||
|
||||
|
||||
class WorkerPoolStatus(BaseModel):
|
||||
active_workers: int
|
||||
queue_size: int
|
||||
total_processed: int
|
||||
total_failed: int
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
service: str
|
||||
version: str
|
||||
timestamp: datetime
|
||||
jenkins: JenkinsStatus
|
||||
worker_pool: WorkerPoolStatus
|
||||
database: Dict[str, Any]
|
||||
|
||||
|
||||
@router.get("/", response_model=HealthResponse)
|
||||
async def health_check(db: Session = Depends(get_db)):
|
||||
"""
|
||||
健康检查端点
|
||||
检查服务各个组件的状态
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
# 检查 Jenkins 连接
|
||||
jenkins_service = get_jenkins_service()
|
||||
jenkins_status = JenkinsStatus(status="disconnected", message="Unable to connect to Jenkins server")
|
||||
|
||||
try:
|
||||
if await jenkins_service.test_connection():
|
||||
jenkins_status = JenkinsStatus(status="connected")
|
||||
except Exception as e:
|
||||
jenkins_status.message = f"Connection failed: {str(e)}"
|
||||
|
||||
# 获取工作池统计
|
||||
queue_service = get_queue_service()
|
||||
try:
|
||||
stats = await queue_service.get_stats()
|
||||
worker_pool_status = WorkerPoolStatus(
|
||||
active_workers=stats.get("active_workers", 0),
|
||||
queue_size=stats.get("queue_size", 0),
|
||||
total_processed=stats.get("total_processed", 0),
|
||||
total_failed=stats.get("total_failed", 0)
|
||||
)
|
||||
except Exception as e:
|
||||
worker_pool_status = WorkerPoolStatus(
|
||||
active_workers=0,
|
||||
queue_size=0,
|
||||
total_processed=0,
|
||||
total_failed=0
|
||||
)
|
||||
|
||||
# 检查数据库连接
|
||||
database_status = {"status": "disconnected", "message": "Database connection failed"}
|
||||
try:
|
||||
# 尝试执行简单查询
|
||||
db.execute("SELECT 1")
|
||||
database_status = {"status": "connected"}
|
||||
except Exception as e:
|
||||
database_status["message"] = f"Database error: {str(e)}"
|
||||
|
||||
# 确定整体状态
|
||||
overall_status = "healthy"
|
||||
if jenkins_status.status != "connected":
|
||||
overall_status = "unhealthy"
|
||||
|
||||
return HealthResponse(
|
||||
status=overall_status,
|
||||
service="Gitea Webhook Ambassador",
|
||||
version=settings.version,
|
||||
timestamp=datetime.utcnow(),
|
||||
jenkins=jenkins_status,
|
||||
worker_pool=worker_pool_status,
|
||||
database=database_status
|
||||
)
|
||||
|
||||
|
||||
@router.get("/simple")
|
||||
async def simple_health_check():
|
||||
"""
|
||||
简单健康检查端点
|
||||
用于负载均衡器和监控系统
|
||||
"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "Gitea Webhook Ambassador",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def readiness_check(db: Session = Depends(get_db)):
|
||||
"""
|
||||
就绪检查端点
|
||||
检查服务是否准备好接收请求
|
||||
"""
|
||||
try:
|
||||
# 检查数据库连接
|
||||
db.execute("SELECT 1")
|
||||
|
||||
# 检查 Jenkins 连接
|
||||
jenkins_service = get_jenkins_service()
|
||||
jenkins_ready = await jenkins_service.test_connection()
|
||||
|
||||
if jenkins_ready:
|
||||
return {"status": "ready"}
|
||||
else:
|
||||
return {"status": "not_ready", "reason": "Jenkins connection failed"}
|
||||
|
||||
except Exception as e:
|
||||
return {"status": "not_ready", "reason": f"Database connection failed: {str(e)}"}
|
||||
|
||||
|
||||
@router.get("/live")
|
||||
async def liveness_check():
|
||||
"""
|
||||
存活检查端点
|
||||
检查服务进程是否正常运行
|
||||
"""
|
||||
return {"status": "alive"}
|
||||
106
apps/gitea-webhook-ambassador-python/app/handlers/logs.py
Normal file
106
apps/gitea-webhook-ambassador-python/app/handlers/logs.py
Normal file
@ -0,0 +1,106 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models.trigger_log import TriggerLog
|
||||
from app.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/logs", tags=["logs"])
|
||||
|
||||
|
||||
class TriggerLogResponse(BaseModel):
|
||||
id: int
|
||||
repository_name: str
|
||||
branch_name: str
|
||||
commit_sha: str
|
||||
job_name: str
|
||||
status: str
|
||||
error_message: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TriggerLogResponse])
|
||||
async def get_trigger_logs(
|
||||
repository: Optional[str] = Query(None, description="Repository name filter"),
|
||||
branch: Optional[str] = Query(None, description="Branch name filter"),
|
||||
since: Optional[str] = Query(None, description="Since timestamp (RFC3339 format)"),
|
||||
limit: int = Query(100, ge=1, le=1000, description="Maximum number of logs to return"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取触发日志
|
||||
"""
|
||||
try:
|
||||
# 构建查询
|
||||
query = db.query(TriggerLog)
|
||||
|
||||
# 应用过滤器
|
||||
if repository:
|
||||
query = query.filter(TriggerLog.repository_name == repository)
|
||||
if branch:
|
||||
query = query.filter(TriggerLog.branch_name == branch)
|
||||
if since:
|
||||
try:
|
||||
since_time = datetime.fromisoformat(since.replace('Z', '+00:00'))
|
||||
query = query.filter(TriggerLog.created_at >= since_time)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid since parameter format (use RFC3339)"
|
||||
)
|
||||
|
||||
# 按时间倒序排列并限制数量
|
||||
logs = query.order_by(TriggerLog.created_at.desc()).limit(limit).all()
|
||||
|
||||
return logs
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get trigger logs: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_log_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
获取日志统计信息
|
||||
"""
|
||||
try:
|
||||
# 总日志数
|
||||
total_logs = db.query(TriggerLog).count()
|
||||
|
||||
# 成功和失败的日志数
|
||||
successful_logs = db.query(TriggerLog).filter(TriggerLog.status == "success").count()
|
||||
failed_logs = db.query(TriggerLog).filter(TriggerLog.status == "failed").count()
|
||||
|
||||
# 最近24小时的日志数
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
recent_logs = db.query(TriggerLog).filter(TriggerLog.created_at >= yesterday).count()
|
||||
|
||||
# 按仓库分组的统计
|
||||
repo_stats = db.query(
|
||||
TriggerLog.repository_name,
|
||||
db.func.count(TriggerLog.id).label('count')
|
||||
).group_by(TriggerLog.repository_name).all()
|
||||
|
||||
return {
|
||||
"total_logs": total_logs,
|
||||
"successful_logs": successful_logs,
|
||||
"failed_logs": failed_logs,
|
||||
"recent_logs_24h": recent_logs,
|
||||
"repository_stats": [
|
||||
{"repository": repo, "count": count}
|
||||
for repo, count in repo_stats
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get log stats: {str(e)}")
|
||||
161
apps/gitea-webhook-ambassador-python/app/handlers/projects.py
Normal file
161
apps/gitea-webhook-ambassador-python/app/handlers/projects.py
Normal file
@ -0,0 +1,161 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
|
||||
from ..models.database import get_db, ProjectMapping, BranchJob, BranchPattern
|
||||
from ..auth.middleware import auth_middleware
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||
|
||||
# 请求/响应模型
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
jenkinsJob: str
|
||||
giteaRepo: str
|
||||
|
||||
class ProjectResponse(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
jenkinsJob: str
|
||||
giteaRepo: str
|
||||
created_at: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ProjectList(BaseModel):
|
||||
projects: List[ProjectResponse]
|
||||
|
||||
@router.post("/", response_model=ProjectResponse)
|
||||
async def create_project(
|
||||
request: ProjectCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(auth_middleware.get_current_user)
|
||||
):
|
||||
"""创建新项目映射"""
|
||||
# 检查项目是否已存在
|
||||
existing_project = db.query(ProjectMapping).filter(
|
||||
ProjectMapping.repository_name == request.giteaRepo
|
||||
).first()
|
||||
|
||||
if existing_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Project with this repository already exists"
|
||||
)
|
||||
|
||||
# 创建新项目
|
||||
project = ProjectMapping(
|
||||
repository_name=request.giteaRepo,
|
||||
default_job=request.jenkinsJob
|
||||
)
|
||||
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
|
||||
return ProjectResponse(
|
||||
id=project.id,
|
||||
name=request.name,
|
||||
jenkinsJob=project.default_job,
|
||||
giteaRepo=project.repository_name,
|
||||
created_at=project.created_at.isoformat()
|
||||
)
|
||||
|
||||
@router.get("/", response_model=ProjectList)
|
||||
async def list_projects(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(auth_middleware.get_current_user)
|
||||
):
|
||||
"""获取所有项目"""
|
||||
projects = db.query(ProjectMapping).order_by(ProjectMapping.created_at.desc()).all()
|
||||
|
||||
return ProjectList(
|
||||
projects=[
|
||||
ProjectResponse(
|
||||
id=project.id,
|
||||
name=project.repository_name.split('/')[-1], # 使用仓库名作为项目名
|
||||
jenkinsJob=project.default_job,
|
||||
giteaRepo=project.repository_name,
|
||||
created_at=project.created_at.isoformat()
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
)
|
||||
|
||||
@router.get("/{project_id}", response_model=ProjectResponse)
|
||||
async def get_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(auth_middleware.get_current_user)
|
||||
):
|
||||
"""获取特定项目"""
|
||||
project = db.query(ProjectMapping).filter(ProjectMapping.id == project_id).first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
return ProjectResponse(
|
||||
id=project.id,
|
||||
name=project.repository_name.split('/')[-1],
|
||||
jenkinsJob=project.default_job,
|
||||
giteaRepo=project.repository_name,
|
||||
created_at=project.created_at.isoformat()
|
||||
)
|
||||
|
||||
@router.delete("/{project_id}")
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(auth_middleware.get_current_user)
|
||||
):
|
||||
"""删除项目"""
|
||||
project = db.query(ProjectMapping).filter(ProjectMapping.id == project_id).first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
db.delete(project)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Project deleted successfully"}
|
||||
|
||||
@router.get("/mapping/{repository_name}")
|
||||
async def get_project_mapping(
|
||||
repository_name: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""根据仓库名获取项目映射(用于 webhook 处理)"""
|
||||
project = db.query(ProjectMapping).filter(
|
||||
ProjectMapping.repository_name == repository_name
|
||||
).first()
|
||||
|
||||
if not project:
|
||||
return None
|
||||
|
||||
return {
|
||||
"id": project.id,
|
||||
"repository_name": project.repository_name,
|
||||
"default_job": project.default_job,
|
||||
"branch_jobs": [
|
||||
{
|
||||
"branch_name": job.branch_name,
|
||||
"job_name": job.job_name
|
||||
}
|
||||
for job in project.branch_jobs
|
||||
],
|
||||
"branch_patterns": [
|
||||
{
|
||||
"pattern": pattern.pattern,
|
||||
"job_name": pattern.job_name
|
||||
}
|
||||
for pattern in project.branch_patterns
|
||||
]
|
||||
}
|
||||
42
apps/gitea-webhook-ambassador-python/app/handlers/webhook.py
Normal file
42
apps/gitea-webhook-ambassador-python/app/handlers/webhook.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""
|
||||
Webhook 处理器
|
||||
处理来自 Gitea 的 webhook 请求
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from app.services.webhook_service import WebhookService
|
||||
from app.services.dedup_service import DeduplicationService
|
||||
from app.tasks.jenkins_tasks import get_celery_app
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def get_webhook_service() -> WebhookService:
|
||||
"""获取 webhook 服务实例"""
|
||||
# 这里应该从依赖注入容器获取
|
||||
# 暂时返回 None,实际使用时需要正确实现
|
||||
return None
|
||||
|
||||
@router.post("/gitea")
|
||||
async def handle_gitea_webhook(
|
||||
request: Request,
|
||||
webhook_service: WebhookService = Depends(get_webhook_service)
|
||||
):
|
||||
"""处理 Gitea webhook 请求"""
|
||||
if webhook_service is None:
|
||||
raise HTTPException(status_code=503, detail="Webhook service not available")
|
||||
|
||||
try:
|
||||
# 获取请求体
|
||||
body = await request.body()
|
||||
|
||||
# 处理 webhook
|
||||
result = await webhook_service.process_webhook(body, request.headers)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Webhook processed successfully",
|
||||
"data": result
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
383
apps/gitea-webhook-ambassador-python/app/main.py
Normal file
383
apps/gitea-webhook-ambassador-python/app/main.py
Normal file
@ -0,0 +1,383 @@
|
||||
"""
|
||||
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()
|
||||
)
|
||||
438
apps/gitea-webhook-ambassador-python/app/main_demo.py
Normal file
438
apps/gitea-webhook-ambassador-python/app/main_demo.py
Normal file
@ -0,0 +1,438 @@
|
||||
from fastapi import FastAPI, Request, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import structlog
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any, Optional, List
|
||||
from pydantic import BaseModel
|
||||
import secrets
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
# 配置日志
|
||||
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()
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title="Gitea Webhook Ambassador (Demo)",
|
||||
description="高性能的 Gitea 到 Jenkins 的 Webhook 服务 - 演示版本",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# 添加 CORS 中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 安全配置
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
# 演示数据存储
|
||||
api_keys = {
|
||||
"demo_admin_key": {
|
||||
"name": "演示管理员密钥",
|
||||
"key_hash": "demo_admin_key",
|
||||
"key_prefix": "demo_adm",
|
||||
"created_at": datetime.utcnow(),
|
||||
"last_used": datetime.utcnow(),
|
||||
"is_active": True,
|
||||
"role": "admin"
|
||||
},
|
||||
"demo_user_key": {
|
||||
"name": "演示用户密钥",
|
||||
"key_hash": "demo_user_key",
|
||||
"key_prefix": "demo_usr",
|
||||
"created_at": datetime.utcnow(),
|
||||
"last_used": datetime.utcnow(),
|
||||
"is_active": True,
|
||||
"role": "user"
|
||||
}
|
||||
}
|
||||
|
||||
trigger_logs = [
|
||||
{
|
||||
"id": 1,
|
||||
"repository_name": "freeleaps/test-project",
|
||||
"branch_name": "main",
|
||||
"commit_sha": "abc123def456",
|
||||
"job_name": "test-project-build",
|
||||
"status": "success",
|
||||
"error_message": None,
|
||||
"created_at": datetime.utcnow() - timedelta(hours=2)
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"repository_name": "freeleaps/another-project",
|
||||
"branch_name": "dev",
|
||||
"commit_sha": "def456ghi789",
|
||||
"job_name": "another-project-dev",
|
||||
"status": "success",
|
||||
"error_message": None,
|
||||
"created_at": datetime.utcnow() - timedelta(hours=1)
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"repository_name": "freeleaps/test-project",
|
||||
"branch_name": "feature/new-feature",
|
||||
"commit_sha": "ghi789jkl012",
|
||||
"job_name": "test-project-feature",
|
||||
"status": "failed",
|
||||
"error_message": "Build timeout",
|
||||
"created_at": datetime.utcnow() - timedelta(minutes=30)
|
||||
}
|
||||
]
|
||||
|
||||
project_mappings = {
|
||||
1: {
|
||||
"repository_name": "freeleaps/test-project",
|
||||
"default_job": "test-project-build",
|
||||
"branch_jobs": [
|
||||
{"branch": "dev", "job": "test-project-dev"},
|
||||
{"branch": "staging", "job": "test-project-staging"}
|
||||
],
|
||||
"branch_patterns": [
|
||||
{"pattern": "feature/*", "job": "test-project-feature"},
|
||||
{"pattern": "hotfix/*", "job": "test-project-hotfix"}
|
||||
],
|
||||
"created_at": datetime.utcnow() - timedelta(days=1),
|
||||
"updated_at": datetime.utcnow() - timedelta(hours=6)
|
||||
}
|
||||
}
|
||||
|
||||
# 请求/响应模型
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
service: str
|
||||
version: str
|
||||
timestamp: datetime
|
||||
jenkins: Dict[str, Any]
|
||||
worker_pool: Dict[str, Any]
|
||||
database: Dict[str, Any]
|
||||
|
||||
class TriggerLogResponse(BaseModel):
|
||||
id: int
|
||||
repository_name: str
|
||||
branch_name: str
|
||||
commit_sha: str
|
||||
job_name: str
|
||||
status: str
|
||||
error_message: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class APIKeyResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
key_prefix: str
|
||||
created_at: datetime
|
||||
last_used: datetime
|
||||
is_active: bool
|
||||
role: str
|
||||
|
||||
class ProjectMappingResponse(BaseModel):
|
||||
id: int
|
||||
repository_name: str
|
||||
default_job: str
|
||||
branch_jobs: List[dict]
|
||||
branch_patterns: List[dict]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
# 认证函数
|
||||
def verify_api_key(api_key: str):
|
||||
"""验证 API 密钥"""
|
||||
for key_id, key_data in api_keys.items():
|
||||
if key_data["key_hash"] == api_key and key_data["is_active"]:
|
||||
# 更新最后使用时间
|
||||
key_data["last_used"] = datetime.utcnow()
|
||||
return key_data
|
||||
return None
|
||||
|
||||
async def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||
):
|
||||
"""获取当前用户(支持 API 密钥认证)"""
|
||||
if not credentials:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="需要认证令牌",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
# 验证 API 密钥
|
||||
api_key_data = verify_api_key(token)
|
||||
if api_key_data:
|
||||
return {
|
||||
"username": api_key_data["name"],
|
||||
"auth_type": "api_key",
|
||||
"role": api_key_data["role"]
|
||||
}
|
||||
|
||||
# 认证失败
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="无效的认证令牌",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
# 公开端点
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
async def health_check():
|
||||
"""健康检查端点"""
|
||||
settings = get_settings()
|
||||
|
||||
return HealthResponse(
|
||||
status="healthy",
|
||||
service="Gitea Webhook Ambassador (Demo)",
|
||||
version=settings.version,
|
||||
timestamp=datetime.utcnow(),
|
||||
jenkins={"status": "connected"},
|
||||
worker_pool={
|
||||
"active_workers": 2,
|
||||
"queue_size": 0,
|
||||
"total_processed": len(trigger_logs),
|
||||
"total_failed": len([log for log in trigger_logs if log["status"] == "failed"])
|
||||
},
|
||||
database={"status": "connected"}
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""根路径"""
|
||||
return {
|
||||
"name": "Gitea Webhook Ambassador (Demo)",
|
||||
"version": "1.0.0",
|
||||
"description": "高性能的 Gitea 到 Jenkins 的 Webhook 服务 - 演示版本",
|
||||
"endpoints": {
|
||||
"webhook": "/webhook/gitea",
|
||||
"health": "/health",
|
||||
"logs": "/api/logs",
|
||||
"admin": "/api/admin"
|
||||
},
|
||||
"demo_keys": {
|
||||
"admin": "demo_admin_key",
|
||||
"user": "demo_user_key"
|
||||
}
|
||||
}
|
||||
|
||||
@app.post("/webhook/gitea")
|
||||
async def handle_gitea_webhook(request: Request):
|
||||
"""处理 Gitea webhook 请求"""
|
||||
try:
|
||||
body = await request.body()
|
||||
|
||||
# 记录 webhook 请求
|
||||
logger.info("Received Gitea webhook",
|
||||
body_size=len(body),
|
||||
headers=dict(request.headers))
|
||||
|
||||
# 添加新的触发日志
|
||||
log_entry = {
|
||||
"id": len(trigger_logs) + 1,
|
||||
"repository_name": "demo-repo",
|
||||
"branch_name": "main",
|
||||
"commit_sha": "demo123",
|
||||
"job_name": "demo-job",
|
||||
"status": "success",
|
||||
"error_message": None,
|
||||
"created_at": datetime.utcnow()
|
||||
}
|
||||
trigger_logs.append(log_entry)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Webhook received successfully",
|
||||
"data": {
|
||||
"body_size": len(body),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Webhook processing failed", error=str(e))
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"success": False,
|
||||
"message": "Webhook processing failed",
|
||||
"error": str(e)
|
||||
}
|
||||
)
|
||||
|
||||
# 需要认证的端点
|
||||
@app.get("/api/logs", response_model=List[TriggerLogResponse])
|
||||
async def get_trigger_logs(
|
||||
repository: Optional[str] = None,
|
||||
branch: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""获取触发日志"""
|
||||
print(f"用户 {current_user['username']} 访问日志端点")
|
||||
|
||||
filtered_logs = trigger_logs.copy()
|
||||
|
||||
if repository:
|
||||
filtered_logs = [log for log in filtered_logs if log["repository_name"] == repository]
|
||||
if branch:
|
||||
filtered_logs = [log for log in filtered_logs if log["branch_name"] == branch]
|
||||
|
||||
# 按时间倒序排列并限制数量
|
||||
filtered_logs.sort(key=lambda x: x["created_at"], reverse=True)
|
||||
return filtered_logs[:limit]
|
||||
|
||||
@app.get("/api/logs/stats")
|
||||
async def get_log_stats(current_user: dict = Depends(get_current_user)):
|
||||
"""获取日志统计信息"""
|
||||
print(f"用户 {current_user['username']} 访问日志统计")
|
||||
|
||||
total_logs = len(trigger_logs)
|
||||
successful_logs = len([log for log in trigger_logs if log["status"] == "success"])
|
||||
failed_logs = len([log for log in trigger_logs if log["status"] == "failed"])
|
||||
|
||||
# 最近24小时的日志数
|
||||
yesterday = datetime.utcnow() - timedelta(days=1)
|
||||
recent_logs = len([log for log in trigger_logs if log["created_at"] >= yesterday])
|
||||
|
||||
# 按仓库分组的统计
|
||||
repo_stats = {}
|
||||
for log in trigger_logs:
|
||||
repo = log["repository_name"]
|
||||
repo_stats[repo] = repo_stats.get(repo, 0) + 1
|
||||
|
||||
return {
|
||||
"total_logs": total_logs,
|
||||
"successful_logs": successful_logs,
|
||||
"failed_logs": failed_logs,
|
||||
"recent_logs_24h": recent_logs,
|
||||
"repository_stats": [
|
||||
{"repository": repo, "count": count}
|
||||
for repo, count in repo_stats.items()
|
||||
]
|
||||
}
|
||||
|
||||
@app.get("/api/admin/api-keys", response_model=List[APIKeyResponse])
|
||||
async def list_api_keys(current_user: dict = Depends(get_current_user)):
|
||||
"""列出所有 API 密钥(仅管理员)"""
|
||||
if current_user["role"] != "admin":
|
||||
raise HTTPException(status_code=403, detail="需要管理员权限")
|
||||
|
||||
print(f"管理员 {current_user['username']} 查看 API 密钥列表")
|
||||
|
||||
return [
|
||||
APIKeyResponse(
|
||||
id=key_id,
|
||||
name=key_data["name"],
|
||||
key_prefix=key_data["key_prefix"],
|
||||
created_at=key_data["created_at"],
|
||||
last_used=key_data["last_used"],
|
||||
is_active=key_data["is_active"],
|
||||
role=key_data["role"]
|
||||
)
|
||||
for key_id, key_data in api_keys.items()
|
||||
]
|
||||
|
||||
@app.get("/api/admin/projects", response_model=List[ProjectMappingResponse])
|
||||
async def list_project_mappings(current_user: dict = Depends(get_current_user)):
|
||||
"""列出所有项目映射"""
|
||||
print(f"用户 {current_user['username']} 查看项目映射")
|
||||
|
||||
return [
|
||||
ProjectMappingResponse(
|
||||
id=mapping_id,
|
||||
repository_name=mapping_data["repository_name"],
|
||||
default_job=mapping_data["default_job"],
|
||||
branch_jobs=mapping_data["branch_jobs"],
|
||||
branch_patterns=mapping_data["branch_patterns"],
|
||||
created_at=mapping_data["created_at"],
|
||||
updated_at=mapping_data["updated_at"]
|
||||
)
|
||||
for mapping_id, mapping_data in project_mappings.items()
|
||||
]
|
||||
|
||||
@app.get("/api/admin/stats")
|
||||
async def get_admin_stats(current_user: dict = Depends(get_current_user)):
|
||||
"""获取管理统计信息"""
|
||||
print(f"用户 {current_user['username']} 查看管理统计")
|
||||
|
||||
total_keys = len(api_keys)
|
||||
active_keys = len([key for key in api_keys.values() if key["is_active"]])
|
||||
|
||||
# 最近使用的密钥
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
recent_keys = len([
|
||||
key for key in api_keys.values()
|
||||
if key["last_used"] >= week_ago
|
||||
])
|
||||
|
||||
total_mappings = len(project_mappings)
|
||||
|
||||
return {
|
||||
"api_keys": {
|
||||
"total": total_keys,
|
||||
"active": active_keys,
|
||||
"recently_used": recent_keys
|
||||
},
|
||||
"project_mappings": {
|
||||
"total": total_mappings
|
||||
}
|
||||
}
|
||||
|
||||
# 中间件
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
"""请求日志中间件"""
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
process_time = (datetime.utcnow() - start_time).total_seconds()
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
return response
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
settings = get_settings()
|
||||
|
||||
print("🚀 启动 Gitea Webhook Ambassador 演示版本")
|
||||
print("=" * 60)
|
||||
print("📋 演示 API 密钥:")
|
||||
print(" 管理员密钥: demo_admin_key")
|
||||
print(" 用户密钥: demo_user_key")
|
||||
print()
|
||||
print("🔧 使用示例:")
|
||||
print(" curl -H 'Authorization: Bearer demo_admin_key' http://localhost:8000/api/admin/api-keys")
|
||||
print(" curl -H 'Authorization: Bearer demo_user_key' http://localhost:8000/api/logs")
|
||||
print("=" * 60)
|
||||
|
||||
uvicorn.run(
|
||||
"app.main_demo:app",
|
||||
host=settings.server.host,
|
||||
port=settings.server.port,
|
||||
reload=settings.server.reload
|
||||
)
|
||||
313
apps/gitea-webhook-ambassador-python/app/main_enhanced.py
Normal file
313
apps/gitea-webhook-ambassador-python/app/main_enhanced.py
Normal file
@ -0,0 +1,313 @@
|
||||
from fastapi import FastAPI, Request, Depends, HTTPException, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
import time
|
||||
import psutil
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 导入数据库模型
|
||||
from app.models.database import create_tables, get_db, APIKey, ProjectMapping, TriggerLog
|
||||
from app.auth.middleware import auth_middleware, get_current_user
|
||||
from app.config import settings
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title="Gitea Webhook Ambassador",
|
||||
description="高性能的 Gitea 到 Jenkins 的 Webhook 服务",
|
||||
version="2.0.0"
|
||||
)
|
||||
|
||||
# 添加 CORS 中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 创建数据库表
|
||||
create_tables()
|
||||
|
||||
# 挂载静态文件
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
|
||||
# 设置模板
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
# 启动时间
|
||||
start_time = datetime.now()
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def root(request: Request):
|
||||
"""根路径 - 重定向到登录页"""
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
"""登录页面"""
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
@app.get("/dashboard", response_class=HTMLResponse)
|
||||
async def dashboard_page(request: Request):
|
||||
"""仪表板页面"""
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def login(request: dict):
|
||||
"""管理员登录"""
|
||||
admin_key = os.getenv("ADMIN_SECRET_KEY", "admin-secret-key-change-in-production")
|
||||
|
||||
if request.get("secret_key") != admin_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid secret key"
|
||||
)
|
||||
|
||||
# 生成 JWT 令牌
|
||||
token = auth_middleware.create_access_token(
|
||||
data={"sub": "admin", "role": "admin"}
|
||||
)
|
||||
|
||||
return {"token": token}
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def get_stats(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||
"""获取统计信息"""
|
||||
try:
|
||||
# 获取项目总数
|
||||
total_projects = db.query(ProjectMapping).count()
|
||||
|
||||
# 获取 API 密钥总数
|
||||
total_api_keys = db.query(APIKey).count()
|
||||
|
||||
# 获取今日触发次数
|
||||
today = datetime.now().date()
|
||||
today_triggers = db.query(TriggerLog).filter(
|
||||
TriggerLog.created_at >= today
|
||||
).count()
|
||||
|
||||
# 获取成功触发次数
|
||||
successful_triggers = db.query(TriggerLog).filter(
|
||||
TriggerLog.status == "success"
|
||||
).count()
|
||||
|
||||
return {
|
||||
"total_projects": total_projects,
|
||||
"total_api_keys": total_api_keys,
|
||||
"today_triggers": today_triggers,
|
||||
"successful_triggers": successful_triggers
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取统计信息失败: {str(e)}")
|
||||
|
||||
@app.get("/api/keys", response_model=dict)
|
||||
async def list_api_keys(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||
"""获取所有 API 密钥(兼容前端)"""
|
||||
try:
|
||||
keys = db.query(APIKey).order_by(APIKey.created_at.desc()).all()
|
||||
return {
|
||||
"keys": [
|
||||
{
|
||||
"id": key.id,
|
||||
"key": key.key,
|
||||
"description": key.description,
|
||||
"created_at": key.created_at.isoformat()
|
||||
}
|
||||
for key in keys
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取 API 密钥失败: {str(e)}")
|
||||
|
||||
@app.post("/api/keys", response_model=dict)
|
||||
async def create_api_key(
|
||||
request: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""创建新的 API 密钥(兼容前端)"""
|
||||
try:
|
||||
# 生成新的 API 密钥
|
||||
api_key_value = auth_middleware.generate_api_key()
|
||||
|
||||
# 保存到数据库
|
||||
db_key = APIKey(
|
||||
key=api_key_value,
|
||||
description=request.get("description", "")
|
||||
)
|
||||
|
||||
db.add(db_key)
|
||||
db.commit()
|
||||
db.refresh(db_key)
|
||||
|
||||
return {
|
||||
"id": db_key.id,
|
||||
"key": db_key.key,
|
||||
"description": db_key.description,
|
||||
"created_at": db_key.created_at.isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建 API 密钥失败: {str(e)}")
|
||||
|
||||
@app.delete("/api/keys/{key_id}")
|
||||
async def delete_api_key(
|
||||
key_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""删除 API 密钥(兼容前端)"""
|
||||
try:
|
||||
key = db.query(APIKey).filter(APIKey.id == key_id).first()
|
||||
|
||||
if not key:
|
||||
raise HTTPException(status_code=404, detail="API 密钥不存在")
|
||||
|
||||
db.delete(key)
|
||||
db.commit()
|
||||
|
||||
return {"message": "API 密钥删除成功"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"删除 API 密钥失败: {str(e)}")
|
||||
|
||||
@app.get("/api/projects/", response_model=dict)
|
||||
async def list_projects(db: Session = Depends(get_db), current_user: dict = Depends(get_current_user)):
|
||||
"""获取所有项目(兼容前端)"""
|
||||
try:
|
||||
projects = db.query(ProjectMapping).order_by(ProjectMapping.created_at.desc()).all()
|
||||
return {
|
||||
"projects": [
|
||||
{
|
||||
"id": project.id,
|
||||
"name": project.repository_name.split('/')[-1],
|
||||
"jenkinsJob": project.default_job,
|
||||
"giteaRepo": project.repository_name,
|
||||
"created_at": project.created_at.isoformat()
|
||||
}
|
||||
for project in projects
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取项目列表失败: {str(e)}")
|
||||
|
||||
@app.post("/api/projects/", response_model=dict)
|
||||
async def create_project(
|
||||
request: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""创建新项目(兼容前端)"""
|
||||
try:
|
||||
# 检查项目是否已存在
|
||||
existing_project = db.query(ProjectMapping).filter(
|
||||
ProjectMapping.repository_name == request["giteaRepo"]
|
||||
).first()
|
||||
|
||||
if existing_project:
|
||||
raise HTTPException(status_code=400, detail="项目已存在")
|
||||
|
||||
# 创建新项目
|
||||
project = ProjectMapping(
|
||||
repository_name=request["giteaRepo"],
|
||||
default_job=request["jenkinsJob"]
|
||||
)
|
||||
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
|
||||
return {
|
||||
"id": project.id,
|
||||
"name": request["name"],
|
||||
"jenkinsJob": project.default_job,
|
||||
"giteaRepo": project.repository_name,
|
||||
"created_at": project.created_at.isoformat()
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"创建项目失败: {str(e)}")
|
||||
|
||||
@app.delete("/api/projects/{project_id}")
|
||||
async def delete_project(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""删除项目(兼容前端)"""
|
||||
try:
|
||||
project = db.query(ProjectMapping).filter(ProjectMapping.id == project_id).first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="项目不存在")
|
||||
|
||||
db.delete(project)
|
||||
db.commit()
|
||||
|
||||
return {"message": "项目删除成功"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"删除项目失败: {str(e)}")
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""健康检查端点"""
|
||||
try:
|
||||
# 计算运行时间
|
||||
uptime = datetime.now() - start_time
|
||||
uptime_str = str(uptime).split('.')[0] # 移除微秒
|
||||
|
||||
# 获取内存使用情况
|
||||
process = psutil.Process()
|
||||
memory_info = process.memory_info()
|
||||
memory_mb = memory_info.rss / 1024 / 1024
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": "2.0.0",
|
||||
"uptime": uptime_str,
|
||||
"memory": f"{memory_mb:.1f} MB",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
@app.get("/api/logs")
|
||||
async def get_logs(
|
||||
startTime: str = None,
|
||||
endTime: str = None,
|
||||
level: str = None,
|
||||
query: str = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user)
|
||||
):
|
||||
"""获取日志(简化版本)"""
|
||||
try:
|
||||
# 这里应该实现真正的日志查询逻辑
|
||||
# 目前返回模拟数据
|
||||
logs = [
|
||||
{
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"level": "info",
|
||||
"message": "系统运行正常"
|
||||
}
|
||||
]
|
||||
|
||||
return {"logs": logs}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"获取日志失败: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
145
apps/gitea-webhook-ambassador-python/app/main_simple.py
Normal file
145
apps/gitea-webhook-ambassador-python/app/main_simple.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""
|
||||
简化版 FastAPI 应用主入口
|
||||
用于快速启动和测试
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import structlog
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import get_settings
|
||||
from app.handlers.webhook import router as webhook_router
|
||||
from app.handlers.health import router as health_router
|
||||
from app.handlers.logs import router as logs_router
|
||||
from app.handlers.admin import router as admin_router
|
||||
|
||||
# 配置日志
|
||||
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()
|
||||
|
||||
# 创建 FastAPI 应用
|
||||
app = FastAPI(
|
||||
title="Gitea Webhook Ambassador",
|
||||
description="高性能的 Gitea 到 Jenkins 的 Webhook 服务",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# 添加 CORS 中间件
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 包含路由
|
||||
app.include_router(webhook_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(logs_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
@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",
|
||||
"logs": "/api/logs",
|
||||
"admin": "/api/admin"
|
||||
}
|
||||
}
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
"""请求日志中间件"""
|
||||
start_time = datetime.utcnow()
|
||||
|
||||
# 记录请求
|
||||
logger.info(
|
||||
"Request started",
|
||||
method=request.method,
|
||||
url=str(request.url),
|
||||
client_ip=request.client.host if request.client else None
|
||||
)
|
||||
|
||||
# 处理请求
|
||||
response = await call_next(request)
|
||||
|
||||
# 计算处理时间
|
||||
process_time = (datetime.utcnow() - start_time).total_seconds()
|
||||
|
||||
# 记录响应
|
||||
logger.info(
|
||||
"Request completed",
|
||||
method=request.method,
|
||||
url=str(request.url),
|
||||
status_code=response.status_code,
|
||||
process_time=process_time
|
||||
)
|
||||
|
||||
# 添加处理时间到响应头
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
|
||||
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={
|
||||
"error": "Internal server error",
|
||||
"message": "An unexpected error occurred"
|
||||
}
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
settings = get_settings()
|
||||
|
||||
logger.info(
|
||||
"Starting Gitea Webhook Ambassador",
|
||||
host=settings.server.host,
|
||||
port=settings.server.port,
|
||||
version=settings.version
|
||||
)
|
||||
|
||||
uvicorn.run(
|
||||
"app.main_simple:app",
|
||||
host=settings.server.host,
|
||||
port=settings.server.port,
|
||||
reload=settings.server.reload
|
||||
)
|
||||
20
apps/gitea-webhook-ambassador-python/app/models/api_key.py
Normal file
20
apps/gitea-webhook-ambassador-python/app/models/api_key.py
Normal file
@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class APIKey(Base):
|
||||
"""API 密钥模型"""
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
key_hash = Column(String(255), nullable=False, unique=True, index=True)
|
||||
key_prefix = Column(String(8), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_used = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<APIKey(id={self.id}, name={self.name}, prefix={self.key_prefix}, active={self.is_active})>"
|
||||
92
apps/gitea-webhook-ambassador-python/app/models/database.py
Normal file
92
apps/gitea-webhook-ambassador-python/app/models/database.py
Normal file
@ -0,0 +1,92 @@
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, relationship
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class APIKey(Base):
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
key = Column(String(255), unique=True, index=True, nullable=False)
|
||||
description = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
class ProjectMapping(Base):
|
||||
__tablename__ = 'project_mappings'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
repository_name = Column(String(255), unique=True, index=True, nullable=False)
|
||||
default_job = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
branch_jobs = relationship("BranchJob", back_populates="project", cascade="all, delete-orphan")
|
||||
branch_patterns = relationship("BranchPattern", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
class BranchJob(Base):
|
||||
__tablename__ = 'branch_jobs'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey('project_mappings.id'), nullable=False)
|
||||
branch_name = Column(String(255), nullable=False)
|
||||
job_name = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
project = relationship("ProjectMapping", back_populates="branch_jobs")
|
||||
|
||||
class BranchPattern(Base):
|
||||
__tablename__ = 'branch_patterns'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey('project_mappings.id'), nullable=False)
|
||||
pattern = Column(String(255), nullable=False)
|
||||
job_name = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
|
||||
# 关系
|
||||
project = relationship("ProjectMapping", back_populates="branch_patterns")
|
||||
|
||||
class TriggerLog(Base):
|
||||
__tablename__ = 'trigger_logs'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
repository_name = Column(String(255), nullable=False)
|
||||
branch_name = Column(String(255), nullable=False)
|
||||
commit_sha = Column(String(255), nullable=False)
|
||||
job_name = Column(String(255), nullable=False)
|
||||
status = Column(String(50), nullable=False) # success, failed, pending
|
||||
error_message = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./gitea_webhook_ambassador.db")
|
||||
|
||||
# 创建引擎
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
|
||||
)
|
||||
|
||||
# 创建会话
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# 创建表
|
||||
def create_tables():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# 获取数据库会话
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
130
apps/gitea-webhook-ambassador-python/app/models/gitea.py
Normal file
130
apps/gitea-webhook-ambassador-python/app/models/gitea.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""
|
||||
Gitea Webhook 数据模型
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
"""Gitea 用户模型"""
|
||||
id: int
|
||||
login: str
|
||||
full_name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
class Commit(BaseModel):
|
||||
"""Git 提交模型"""
|
||||
id: str
|
||||
message: str
|
||||
url: str
|
||||
author: User
|
||||
timestamp: Optional[datetime] = None
|
||||
|
||||
|
||||
class Repository(BaseModel):
|
||||
"""Git 仓库模型"""
|
||||
id: int
|
||||
name: str
|
||||
owner: User
|
||||
full_name: str
|
||||
private: bool = False
|
||||
clone_url: str
|
||||
ssh_url: Optional[str] = None
|
||||
html_url: str
|
||||
default_branch: str = "main"
|
||||
|
||||
|
||||
class GiteaWebhook(BaseModel):
|
||||
"""Gitea Webhook 模型"""
|
||||
secret: Optional[str] = None
|
||||
ref: str
|
||||
before: str
|
||||
after: str
|
||||
compare_url: Optional[str] = None
|
||||
commits: List[Commit] = Field(default_factory=list)
|
||||
repository: Repository
|
||||
pusher: User
|
||||
|
||||
def get_branch_name(self) -> str:
|
||||
"""从 ref 中提取分支名"""
|
||||
prefix = "refs/heads/"
|
||||
if self.ref.startswith(prefix):
|
||||
return self.ref[len(prefix):]
|
||||
return self.ref
|
||||
|
||||
def get_event_id(self) -> str:
|
||||
"""生成唯一的事件 ID"""
|
||||
return f"{self.repository.full_name}-{self.after}"
|
||||
|
||||
def get_commit_hash(self) -> str:
|
||||
"""获取提交哈希"""
|
||||
return self.after
|
||||
|
||||
def get_deduplication_key(self) -> str:
|
||||
"""生成防抖键值"""
|
||||
branch = self.get_branch_name()
|
||||
return f"{self.after}:{branch}"
|
||||
|
||||
def is_push_event(self) -> bool:
|
||||
"""判断是否为推送事件"""
|
||||
return self.ref.startswith("refs/heads/")
|
||||
|
||||
def is_tag_event(self) -> bool:
|
||||
"""判断是否为标签事件"""
|
||||
return self.ref.startswith("refs/tags/")
|
||||
|
||||
def get_commit_message(self) -> str:
|
||||
"""获取提交信息"""
|
||||
if self.commits:
|
||||
return self.commits[0].message
|
||||
return ""
|
||||
|
||||
def get_author_info(self) -> dict:
|
||||
"""获取作者信息"""
|
||||
if self.commits:
|
||||
author = self.commits[0].author
|
||||
return {
|
||||
"name": author.full_name or author.login,
|
||||
"email": author.email,
|
||||
"username": author.login
|
||||
}
|
||||
return {
|
||||
"name": self.pusher.full_name or self.pusher.login,
|
||||
"email": self.pusher.email,
|
||||
"username": self.pusher.login
|
||||
}
|
||||
|
||||
|
||||
class WebhookEvent(BaseModel):
|
||||
"""Webhook 事件模型"""
|
||||
id: str
|
||||
repository: str
|
||||
branch: str
|
||||
commit_hash: str
|
||||
event_type: str
|
||||
timestamp: datetime
|
||||
payload: dict
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
|
||||
|
||||
class WebhookResponse(BaseModel):
|
||||
"""Webhook 响应模型"""
|
||||
success: bool
|
||||
message: str
|
||||
event_id: Optional[str] = None
|
||||
job_name: Optional[str] = None
|
||||
environment: Optional[str] = None
|
||||
timestamp: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
class Config:
|
||||
json_encoders = {
|
||||
datetime: lambda v: v.isoformat()
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, JSON
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class ProjectMapping(Base):
|
||||
"""项目映射模型"""
|
||||
__tablename__ = "project_mappings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
repository_name = Column(String(255), nullable=False, unique=True, index=True)
|
||||
default_job = Column(String(255), nullable=False)
|
||||
branch_jobs = Column(JSON, nullable=False, default=list) # [{"branch": "dev", "job": "dev-build"}]
|
||||
branch_patterns = Column(JSON, nullable=False, default=list) # [{"pattern": "feature/*", "job": "feature-build"}]
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProjectMapping(id={self.id}, repository={self.repository_name}, default_job={self.default_job})>"
|
||||
@ -0,0 +1,22 @@
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
class TriggerLog(Base):
|
||||
"""触发日志模型"""
|
||||
__tablename__ = "trigger_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
repository_name = Column(String(255), nullable=False, index=True)
|
||||
branch_name = Column(String(255), nullable=False, index=True)
|
||||
commit_sha = Column(String(64), nullable=False)
|
||||
job_name = Column(String(255), nullable=False)
|
||||
status = Column(String(50), nullable=False, index=True) # success, failed, pending
|
||||
error_message = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TriggerLog(id={self.id}, repository={self.repository_name}, branch={self.branch_name}, status={self.status})>"
|
||||
@ -0,0 +1,398 @@
|
||||
"""
|
||||
数据库服务
|
||||
实现项目映射、分支模式匹配等功能
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
import re
|
||||
from sqlalchemy import create_engine, Column, Integer, String, DateTime, Text, ForeignKey
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, relationship
|
||||
from sqlalchemy.sql import text
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# 数据库模型
|
||||
class APIKey(Base):
|
||||
"""API 密钥模型"""
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
key = Column(String(255), unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class ProjectMapping(Base):
|
||||
"""项目映射模型"""
|
||||
__tablename__ = "project_mappings"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
repository_name = Column(String(255), unique=True, nullable=False)
|
||||
default_job = Column(String(255))
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 关系
|
||||
branch_jobs = relationship("BranchJob", back_populates="project", cascade="all, delete-orphan")
|
||||
branch_patterns = relationship("BranchPattern", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class BranchJob(Base):
|
||||
"""分支任务映射模型"""
|
||||
__tablename__ = "branch_jobs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
project_id = Column(Integer, ForeignKey("project_mappings.id"), nullable=False)
|
||||
branch_name = Column(String(255), nullable=False)
|
||||
job_name = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 关系
|
||||
project = relationship("ProjectMapping", back_populates="branch_jobs")
|
||||
|
||||
|
||||
class BranchPattern(Base):
|
||||
"""分支模式映射模型"""
|
||||
__tablename__ = "branch_patterns"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
project_id = Column(Integer, ForeignKey("project_mappings.id"), nullable=False)
|
||||
pattern = Column(String(255), nullable=False)
|
||||
job_name = Column(String(255), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# 关系
|
||||
project = relationship("ProjectMapping", back_populates="branch_patterns")
|
||||
|
||||
|
||||
class TriggerLog(Base):
|
||||
"""触发日志模型"""
|
||||
__tablename__ = "trigger_logs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
repository_name = Column(String(255), nullable=False)
|
||||
branch_name = Column(String(255), nullable=False)
|
||||
commit_sha = Column(String(255), nullable=False)
|
||||
job_name = Column(String(255), nullable=False)
|
||||
status = Column(String(50), nullable=False)
|
||||
error_message = Column(Text)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class DatabaseService:
|
||||
"""数据库服务"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.engine = None
|
||||
self.SessionLocal = None
|
||||
self._init_database()
|
||||
|
||||
def _init_database(self):
|
||||
"""初始化数据库"""
|
||||
try:
|
||||
self.engine = create_engine(
|
||||
self.settings.database.url,
|
||||
echo=self.settings.database.echo,
|
||||
pool_size=self.settings.database.pool_size,
|
||||
max_overflow=self.settings.database.max_overflow
|
||||
)
|
||||
|
||||
# 创建表
|
||||
Base.metadata.create_all(bind=self.engine)
|
||||
|
||||
# 创建会话工厂
|
||||
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=self.engine)
|
||||
|
||||
logger.info("Database initialized successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize database", error=str(e))
|
||||
raise
|
||||
|
||||
def get_session(self):
|
||||
"""获取数据库会话"""
|
||||
return self.SessionLocal()
|
||||
|
||||
async def get_project_mapping(self, repository_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
获取项目映射
|
||||
|
||||
Args:
|
||||
repository_name: 仓库名
|
||||
|
||||
Returns:
|
||||
Dict: 项目映射信息
|
||||
"""
|
||||
try:
|
||||
def _get_mapping():
|
||||
session = self.get_session()
|
||||
try:
|
||||
project = session.query(ProjectMapping).filter(
|
||||
ProjectMapping.repository_name == repository_name
|
||||
).first()
|
||||
|
||||
if not project:
|
||||
return None
|
||||
|
||||
# 构建返回数据
|
||||
result = {
|
||||
"id": project.id,
|
||||
"repository_name": project.repository_name,
|
||||
"default_job": project.default_job,
|
||||
"branch_jobs": [],
|
||||
"branch_patterns": []
|
||||
}
|
||||
|
||||
# 添加分支任务映射
|
||||
for branch_job in project.branch_jobs:
|
||||
result["branch_jobs"].append({
|
||||
"id": branch_job.id,
|
||||
"branch_name": branch_job.branch_name,
|
||||
"job_name": branch_job.job_name
|
||||
})
|
||||
|
||||
# 添加分支模式映射
|
||||
for pattern in project.branch_patterns:
|
||||
result["branch_patterns"].append({
|
||||
"id": pattern.id,
|
||||
"pattern": pattern.pattern,
|
||||
"job_name": pattern.job_name
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
# 在线程池中执行数据库操作
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _get_mapping)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get project mapping",
|
||||
repository_name=repository_name, error=str(e))
|
||||
return None
|
||||
|
||||
async def determine_job_name(self, repository_name: str, branch_name: str) -> Optional[str]:
|
||||
"""
|
||||
根据分支名确定任务名
|
||||
|
||||
Args:
|
||||
repository_name: 仓库名
|
||||
branch_name: 分支名
|
||||
|
||||
Returns:
|
||||
str: 任务名
|
||||
"""
|
||||
try:
|
||||
project = await self.get_project_mapping(repository_name)
|
||||
if not project:
|
||||
return None
|
||||
|
||||
# 1. 检查精确分支匹配
|
||||
for branch_job in project["branch_jobs"]:
|
||||
if branch_job["branch_name"] == branch_name:
|
||||
logger.debug("Found exact branch match",
|
||||
branch=branch_name, job=branch_job["job_name"])
|
||||
return branch_job["job_name"]
|
||||
|
||||
# 2. 检查模式匹配
|
||||
for pattern in project["branch_patterns"]:
|
||||
try:
|
||||
if re.match(pattern["pattern"], branch_name):
|
||||
logger.debug("Branch matched pattern",
|
||||
branch=branch_name, pattern=pattern["pattern"],
|
||||
job=pattern["job_name"])
|
||||
return pattern["job_name"]
|
||||
except re.error as e:
|
||||
logger.error("Invalid regex pattern",
|
||||
pattern=pattern["pattern"], error=str(e))
|
||||
continue
|
||||
|
||||
# 3. 使用默认任务
|
||||
if project["default_job"]:
|
||||
logger.debug("Using default job",
|
||||
branch=branch_name, job=project["default_job"])
|
||||
return project["default_job"]
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to determine job name",
|
||||
repository_name=repository_name, branch_name=branch_name,
|
||||
error=str(e))
|
||||
return None
|
||||
|
||||
async def log_trigger(self, log_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
记录触发日志
|
||||
|
||||
Args:
|
||||
log_data: 日志数据
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
try:
|
||||
def _log_trigger():
|
||||
session = self.get_session()
|
||||
try:
|
||||
log = TriggerLog(
|
||||
repository_name=log_data["repository_name"],
|
||||
branch_name=log_data["branch_name"],
|
||||
commit_sha=log_data["commit_sha"],
|
||||
job_name=log_data["job_name"],
|
||||
status=log_data["status"],
|
||||
error_message=log_data.get("error_message")
|
||||
)
|
||||
|
||||
session.add(log)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Failed to log trigger", error=str(e))
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _log_trigger)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to log trigger", error=str(e))
|
||||
return False
|
||||
|
||||
async def get_trigger_logs(self, repository_name: str = None,
|
||||
branch_name: str = None, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取触发日志
|
||||
|
||||
Args:
|
||||
repository_name: 仓库名(可选)
|
||||
branch_name: 分支名(可选)
|
||||
limit: 限制数量
|
||||
|
||||
Returns:
|
||||
List: 日志列表
|
||||
"""
|
||||
try:
|
||||
def _get_logs():
|
||||
session = self.get_session()
|
||||
try:
|
||||
query = session.query(TriggerLog)
|
||||
|
||||
if repository_name:
|
||||
query = query.filter(TriggerLog.repository_name == repository_name)
|
||||
|
||||
if branch_name:
|
||||
query = query.filter(TriggerLog.branch_name == branch_name)
|
||||
|
||||
logs = query.order_by(TriggerLog.created_at.desc()).limit(limit).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"repository_name": log.repository_name,
|
||||
"branch_name": log.branch_name,
|
||||
"commit_sha": log.commit_sha,
|
||||
"job_name": log.job_name,
|
||||
"status": log.status,
|
||||
"error_message": log.error_message,
|
||||
"created_at": log.created_at.isoformat()
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _get_logs)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get trigger logs", error=str(e))
|
||||
return []
|
||||
|
||||
async def create_project_mapping(self, mapping_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
创建项目映射
|
||||
|
||||
Args:
|
||||
mapping_data: 映射数据
|
||||
|
||||
Returns:
|
||||
bool: 是否成功
|
||||
"""
|
||||
try:
|
||||
def _create_mapping():
|
||||
session = self.get_session()
|
||||
try:
|
||||
# 创建项目映射
|
||||
project = ProjectMapping(
|
||||
repository_name=mapping_data["repository_name"],
|
||||
default_job=mapping_data.get("default_job")
|
||||
)
|
||||
|
||||
session.add(project)
|
||||
session.flush() # 获取 ID
|
||||
|
||||
# 添加分支任务映射
|
||||
for branch_job in mapping_data.get("branch_jobs", []):
|
||||
job = BranchJob(
|
||||
project_id=project.id,
|
||||
branch_name=branch_job["branch_name"],
|
||||
job_name=branch_job["job_name"]
|
||||
)
|
||||
session.add(job)
|
||||
|
||||
# 添加分支模式映射
|
||||
for pattern in mapping_data.get("branch_patterns", []):
|
||||
pattern_obj = BranchPattern(
|
||||
project_id=project.id,
|
||||
pattern=pattern["pattern"],
|
||||
job_name=pattern["job_name"]
|
||||
)
|
||||
session.add(pattern_obj)
|
||||
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
logger.error("Failed to create project mapping", error=str(e))
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _create_mapping)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to create project mapping", error=str(e))
|
||||
return False
|
||||
|
||||
|
||||
# 全局数据库服务实例
|
||||
_database_service: Optional[DatabaseService] = None
|
||||
|
||||
|
||||
def get_database_service() -> DatabaseService:
|
||||
"""获取数据库服务实例"""
|
||||
global _database_service
|
||||
if _database_service is None:
|
||||
_database_service = DatabaseService()
|
||||
return _database_service
|
||||
@ -0,0 +1,223 @@
|
||||
"""
|
||||
防抖服务
|
||||
实现基于 commit hash + 分支的去重策略
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
import structlog
|
||||
from redis import asyncio as aioredis
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class DeduplicationService:
|
||||
"""防抖服务"""
|
||||
|
||||
def __init__(self, redis_client: aioredis.Redis):
|
||||
self.redis = redis_client
|
||||
self.settings = get_settings()
|
||||
self.cache_prefix = "webhook:dedup:"
|
||||
|
||||
async def is_duplicate(self, dedup_key: str) -> bool:
|
||||
"""
|
||||
检查是否为重复事件
|
||||
|
||||
Args:
|
||||
dedup_key: 防抖键值 (commit_hash:branch)
|
||||
|
||||
Returns:
|
||||
bool: True 表示重复,False 表示新事件
|
||||
"""
|
||||
if not self.settings.deduplication.enabled:
|
||||
return False
|
||||
|
||||
try:
|
||||
cache_key = f"{self.cache_prefix}{dedup_key}"
|
||||
|
||||
# 检查是否在缓存中
|
||||
exists = await self.redis.exists(cache_key)
|
||||
if exists:
|
||||
logger.info("Duplicate event detected", dedup_key=dedup_key)
|
||||
return True
|
||||
|
||||
# 记录新事件
|
||||
await self._record_event(cache_key, dedup_key)
|
||||
logger.info("New event recorded", dedup_key=dedup_key)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error checking duplication",
|
||||
dedup_key=dedup_key, error=str(e))
|
||||
# 出错时允许通过,避免阻塞
|
||||
return False
|
||||
|
||||
async def _record_event(self, cache_key: str, dedup_key: str):
|
||||
"""记录事件到缓存"""
|
||||
try:
|
||||
# 设置缓存,TTL 为防抖窗口时间
|
||||
ttl = self.settings.deduplication.cache_ttl
|
||||
await self.redis.setex(cache_key, ttl, json.dumps({
|
||||
"dedup_key": dedup_key,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"ttl": ttl
|
||||
}))
|
||||
|
||||
# 同时记录到时间窗口缓存
|
||||
window_key = f"{self.cache_prefix}window:{dedup_key}"
|
||||
window_ttl = self.settings.deduplication.window_seconds
|
||||
await self.redis.setex(window_key, window_ttl, "1")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error recording event",
|
||||
cache_key=cache_key, error=str(e))
|
||||
|
||||
async def get_event_info(self, dedup_key: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取事件信息"""
|
||||
try:
|
||||
cache_key = f"{self.cache_prefix}{dedup_key}"
|
||||
data = await self.redis.get(cache_key)
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Error getting event info",
|
||||
dedup_key=dedup_key, error=str(e))
|
||||
return None
|
||||
|
||||
async def clear_event(self, dedup_key: str) -> bool:
|
||||
"""清除事件记录"""
|
||||
try:
|
||||
cache_key = f"{self.cache_prefix}{dedup_key}"
|
||||
window_key = f"{self.cache_prefix}window:{dedup_key}"
|
||||
|
||||
# 删除两个缓存键
|
||||
await self.redis.delete(cache_key, window_key)
|
||||
logger.info("Event cleared", dedup_key=dedup_key)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error clearing event",
|
||||
dedup_key=dedup_key, error=str(e))
|
||||
return False
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""获取防抖统计信息"""
|
||||
try:
|
||||
# 获取所有防抖键
|
||||
pattern = f"{self.cache_prefix}*"
|
||||
keys = await self.redis.keys(pattern)
|
||||
|
||||
# 统计不同类型的键
|
||||
total_keys = len(keys)
|
||||
window_keys = len([k for k in keys if b"window:" in k])
|
||||
event_keys = total_keys - window_keys
|
||||
|
||||
# 获取配置信息
|
||||
config = {
|
||||
"enabled": self.settings.deduplication.enabled,
|
||||
"window_seconds": self.settings.deduplication.window_seconds,
|
||||
"cache_ttl": self.settings.deduplication.cache_ttl,
|
||||
"strategy": self.settings.deduplication.strategy
|
||||
}
|
||||
|
||||
return {
|
||||
"total_keys": total_keys,
|
||||
"window_keys": window_keys,
|
||||
"event_keys": event_keys,
|
||||
"config": config,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting deduplication stats", error=str(e))
|
||||
return {"error": str(e)}
|
||||
|
||||
async def cleanup_expired_events(self) -> int:
|
||||
"""清理过期事件"""
|
||||
try:
|
||||
pattern = f"{self.cache_prefix}*"
|
||||
keys = await self.redis.keys(pattern)
|
||||
|
||||
cleaned_count = 0
|
||||
for key in keys:
|
||||
# 检查 TTL
|
||||
ttl = await self.redis.ttl(key)
|
||||
if ttl <= 0:
|
||||
await self.redis.delete(key)
|
||||
cleaned_count += 1
|
||||
|
||||
if cleaned_count > 0:
|
||||
logger.info("Cleaned up expired events", count=cleaned_count)
|
||||
|
||||
return cleaned_count
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error cleaning up expired events", error=str(e))
|
||||
return 0
|
||||
|
||||
def generate_dedup_key(self, commit_hash: str, branch: str) -> str:
|
||||
"""
|
||||
生成防抖键值
|
||||
|
||||
Args:
|
||||
commit_hash: 提交哈希
|
||||
branch: 分支名
|
||||
|
||||
Returns:
|
||||
str: 防抖键值
|
||||
"""
|
||||
if self.settings.deduplication.strategy == "commit_branch":
|
||||
return f"{commit_hash}:{branch}"
|
||||
elif self.settings.deduplication.strategy == "commit_only":
|
||||
return commit_hash
|
||||
elif self.settings.deduplication.strategy == "branch_only":
|
||||
return branch
|
||||
else:
|
||||
# 默认使用 commit_hash:branch
|
||||
return f"{commit_hash}:{branch}"
|
||||
|
||||
async def is_in_window(self, dedup_key: str) -> bool:
|
||||
"""
|
||||
检查是否在防抖时间窗口内
|
||||
|
||||
Args:
|
||||
dedup_key: 防抖键值
|
||||
|
||||
Returns:
|
||||
bool: True 表示在窗口内
|
||||
"""
|
||||
try:
|
||||
window_key = f"{self.cache_prefix}window:{dedup_key}"
|
||||
exists = await self.redis.exists(window_key)
|
||||
return bool(exists)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error checking window",
|
||||
dedup_key=dedup_key, error=str(e))
|
||||
return False
|
||||
|
||||
|
||||
# 全局防抖服务实例
|
||||
_dedup_service: Optional[DeduplicationService] = None
|
||||
|
||||
|
||||
def get_deduplication_service() -> DeduplicationService:
|
||||
"""获取防抖服务实例"""
|
||||
global _dedup_service
|
||||
if _dedup_service is None:
|
||||
# 这里需要从依赖注入获取 Redis 客户端
|
||||
# 在实际使用时,应该通过依赖注入传入
|
||||
raise RuntimeError("DeduplicationService not initialized")
|
||||
return _dedup_service
|
||||
|
||||
|
||||
def set_deduplication_service(service: DeduplicationService):
|
||||
"""设置防抖服务实例"""
|
||||
global _dedup_service
|
||||
_dedup_service = service
|
||||
@ -0,0 +1,100 @@
|
||||
"""
|
||||
Jenkins 服务
|
||||
提供与 Jenkins 的交互功能
|
||||
"""
|
||||
|
||||
import aiohttp
|
||||
import structlog
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
class JenkinsService:
|
||||
"""Jenkins 服务类"""
|
||||
|
||||
def __init__(self):
|
||||
self.settings = get_settings()
|
||||
self.base_url = self.settings.jenkins.url
|
||||
self.username = self.settings.jenkins.username
|
||||
self.token = self.settings.jenkins.token
|
||||
self.timeout = self.settings.jenkins.timeout
|
||||
|
||||
async def test_connection(self) -> bool:
|
||||
"""测试 Jenkins 连接"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
auth = aiohttp.BasicAuth(self.username, self.token)
|
||||
async with session.get(
|
||||
f"{self.base_url}/api/json",
|
||||
auth=auth,
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout)
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
logger.info("Jenkins connection test successful")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Jenkins connection test failed with status {response.status}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Jenkins connection test failed: {str(e)}")
|
||||
return False
|
||||
|
||||
async def trigger_job(self, job_name: str, parameters: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""触发 Jenkins 任务"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
auth = aiohttp.BasicAuth(self.username, self.token)
|
||||
|
||||
# 构建请求 URL
|
||||
url = f"{self.base_url}/job/{job_name}/build"
|
||||
|
||||
# 如果有参数,使用参数化构建
|
||||
if parameters:
|
||||
url = f"{self.base_url}/job/{job_name}/buildWithParameters"
|
||||
|
||||
async with session.post(
|
||||
url,
|
||||
auth=auth,
|
||||
params=parameters or {},
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout)
|
||||
) as response:
|
||||
if response.status in [200, 201]:
|
||||
logger.info(f"Successfully triggered Jenkins job: {job_name}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to trigger Jenkins job {job_name}: {response.status}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error triggering Jenkins job {job_name}: {str(e)}")
|
||||
return False
|
||||
|
||||
async def get_job_info(self, job_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""获取任务信息"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
auth = aiohttp.BasicAuth(self.username, self.token)
|
||||
async with session.get(
|
||||
f"{self.base_url}/job/{job_name}/api/json",
|
||||
auth=auth,
|
||||
timeout=aiohttp.ClientTimeout(total=self.timeout)
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
logger.warning(f"Failed to get job info for {job_name}: {response.status}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting job info for {job_name}: {str(e)}")
|
||||
return None
|
||||
|
||||
# 全局服务实例
|
||||
_jenkins_service = None
|
||||
|
||||
def get_jenkins_service() -> JenkinsService:
|
||||
"""获取 Jenkins 服务实例"""
|
||||
global _jenkins_service
|
||||
if _jenkins_service is None:
|
||||
_jenkins_service = JenkinsService()
|
||||
return _jenkins_service
|
||||
@ -0,0 +1,70 @@
|
||||
"""
|
||||
队列服务
|
||||
提供任务队列管理功能
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
class QueueService:
|
||||
"""队列服务类"""
|
||||
|
||||
def __init__(self):
|
||||
self.active_workers = 0
|
||||
self.queue_size = 0
|
||||
self.total_processed = 0
|
||||
self.total_failed = 0
|
||||
self._stats = {
|
||||
"active_workers": 0,
|
||||
"queue_size": 0,
|
||||
"total_processed": 0,
|
||||
"total_failed": 0
|
||||
}
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""获取队列统计信息"""
|
||||
return self._stats.copy()
|
||||
|
||||
async def increment_processed(self):
|
||||
"""增加已处理任务计数"""
|
||||
self.total_processed += 1
|
||||
self._stats["total_processed"] = self.total_processed
|
||||
|
||||
async def increment_failed(self):
|
||||
"""增加失败任务计数"""
|
||||
self.total_failed += 1
|
||||
self._stats["total_failed"] = self.total_failed
|
||||
|
||||
async def set_active_workers(self, count: int):
|
||||
"""设置活跃工作线程数"""
|
||||
self.active_workers = count
|
||||
self._stats["active_workers"] = count
|
||||
|
||||
async def set_queue_size(self, size: int):
|
||||
"""设置队列大小"""
|
||||
self.queue_size = size
|
||||
self._stats["queue_size"] = size
|
||||
|
||||
async def add_to_queue(self):
|
||||
"""添加任务到队列"""
|
||||
self.queue_size += 1
|
||||
self._stats["queue_size"] = self.queue_size
|
||||
|
||||
async def remove_from_queue(self):
|
||||
"""从队列移除任务"""
|
||||
if self.queue_size > 0:
|
||||
self.queue_size -= 1
|
||||
self._stats["queue_size"] = self.queue_size
|
||||
|
||||
# 全局服务实例
|
||||
_queue_service = None
|
||||
|
||||
def get_queue_service() -> QueueService:
|
||||
"""获取队列服务实例"""
|
||||
global _queue_service
|
||||
if _queue_service is None:
|
||||
_queue_service = QueueService()
|
||||
return _queue_service
|
||||
@ -0,0 +1,280 @@
|
||||
"""
|
||||
Webhook 处理服务
|
||||
实现智能分发、任务排队和防抖策略
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
from celery import Celery
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.gitea import GiteaWebhook, WebhookResponse
|
||||
from app.services.dedup_service import DeduplicationService
|
||||
from app.services.jenkins_service import JenkinsService
|
||||
from app.services.database_service import get_database_service
|
||||
from app.tasks.jenkins_tasks import trigger_jenkins_job
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class WebhookService:
|
||||
"""Webhook 处理服务"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dedup_service: DeduplicationService,
|
||||
jenkins_service: JenkinsService,
|
||||
celery_app: Celery
|
||||
):
|
||||
self.dedup_service = dedup_service
|
||||
self.jenkins_service = jenkins_service
|
||||
self.celery_app = celery_app
|
||||
self.settings = get_settings()
|
||||
self.db_service = get_database_service()
|
||||
|
||||
async def process_webhook(self, webhook: GiteaWebhook) -> WebhookResponse:
|
||||
"""
|
||||
处理 Webhook 事件
|
||||
|
||||
Args:
|
||||
webhook: Gitea Webhook 数据
|
||||
|
||||
Returns:
|
||||
WebhookResponse: 处理结果
|
||||
"""
|
||||
try:
|
||||
# 1. 验证事件类型
|
||||
if not webhook.is_push_event():
|
||||
return WebhookResponse(
|
||||
success=True,
|
||||
message="Non-push event ignored",
|
||||
event_id=webhook.get_event_id()
|
||||
)
|
||||
|
||||
# 2. 提取关键信息
|
||||
branch = webhook.get_branch_name()
|
||||
commit_hash = webhook.get_commit_hash()
|
||||
repository = webhook.repository.full_name
|
||||
|
||||
logger.info("Processing webhook",
|
||||
repository=repository,
|
||||
branch=branch,
|
||||
commit_hash=commit_hash)
|
||||
|
||||
# 3. 防抖检查
|
||||
dedup_key = self.dedup_service.generate_dedup_key(commit_hash, branch)
|
||||
if await self.dedup_service.is_duplicate(dedup_key):
|
||||
return WebhookResponse(
|
||||
success=True,
|
||||
message="Duplicate event ignored",
|
||||
event_id=webhook.get_event_id()
|
||||
)
|
||||
|
||||
# 4. 获取项目映射和任务名
|
||||
job_name = await self._determine_job_name(repository, branch)
|
||||
if not job_name:
|
||||
return WebhookResponse(
|
||||
success=True,
|
||||
message=f"No Jenkins job mapping for repository: {repository}, branch: {branch}",
|
||||
event_id=webhook.get_event_id()
|
||||
)
|
||||
|
||||
# 5. 准备任务参数
|
||||
job_params = self._prepare_job_parameters(webhook, job_name)
|
||||
|
||||
# 6. 提交任务到队列
|
||||
task_result = await self._submit_job_to_queue(
|
||||
webhook, job_name, job_params
|
||||
)
|
||||
|
||||
if task_result:
|
||||
return WebhookResponse(
|
||||
success=True,
|
||||
message="Job queued successfully",
|
||||
event_id=webhook.get_event_id(),
|
||||
job_name=job_name
|
||||
)
|
||||
else:
|
||||
return WebhookResponse(
|
||||
success=False,
|
||||
message="Failed to queue job",
|
||||
event_id=webhook.get_event_id()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing webhook",
|
||||
repository=webhook.repository.full_name,
|
||||
error=str(e))
|
||||
return WebhookResponse(
|
||||
success=False,
|
||||
message=f"Internal server error: {str(e)}",
|
||||
event_id=webhook.get_event_id()
|
||||
)
|
||||
|
||||
async def _determine_job_name(self, repository: str, branch: str) -> Optional[str]:
|
||||
"""根据仓库和分支确定任务名"""
|
||||
# 首先尝试从数据库获取项目映射
|
||||
job_name = await self.db_service.determine_job_name(repository, branch)
|
||||
if job_name:
|
||||
return job_name
|
||||
|
||||
# 如果数据库中没有映射,使用配置文件中的环境分发
|
||||
environment = self.settings.get_environment_for_branch(branch)
|
||||
if environment:
|
||||
return environment.jenkins_job
|
||||
|
||||
return None
|
||||
|
||||
def _prepare_job_parameters(self, webhook: GiteaWebhook, job_name: str) -> Dict[str, str]:
|
||||
"""准备 Jenkins 任务参数"""
|
||||
author_info = webhook.get_author_info()
|
||||
|
||||
return {
|
||||
"BRANCH_NAME": webhook.get_branch_name(),
|
||||
"COMMIT_SHA": webhook.get_commit_hash(),
|
||||
"REPOSITORY_URL": webhook.repository.clone_url,
|
||||
"REPOSITORY_NAME": webhook.repository.full_name,
|
||||
"PUSHER_NAME": author_info["name"],
|
||||
"PUSHER_EMAIL": author_info["email"],
|
||||
"PUSHER_USERNAME": author_info["username"],
|
||||
"COMMIT_MESSAGE": webhook.get_commit_message(),
|
||||
"JOB_NAME": job_name,
|
||||
"WEBHOOK_EVENT_ID": webhook.get_event_id(),
|
||||
"TRIGGER_TIME": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
async def _submit_job_to_queue(
|
||||
self,
|
||||
webhook: GiteaWebhook,
|
||||
job_name: str,
|
||||
job_params: Dict[str, str]
|
||||
) -> bool:
|
||||
"""提交任务到 Celery 队列"""
|
||||
try:
|
||||
# 创建任务
|
||||
task_kwargs = {
|
||||
"job_name": job_name,
|
||||
"jenkins_url": self.settings.jenkins.url,
|
||||
"parameters": job_params,
|
||||
"event_id": webhook.get_event_id(),
|
||||
"repository": webhook.repository.full_name,
|
||||
"branch": webhook.get_branch_name(),
|
||||
"commit_hash": webhook.get_commit_hash(),
|
||||
"priority": 1 # 默认优先级
|
||||
}
|
||||
|
||||
# 提交到 Celery 队列
|
||||
task = self.celery_app.send_task(
|
||||
"app.tasks.jenkins_tasks.trigger_jenkins_job",
|
||||
kwargs=task_kwargs,
|
||||
priority=environment.priority
|
||||
)
|
||||
|
||||
logger.info("Job submitted to queue",
|
||||
task_id=task.id,
|
||||
job_name=job_name,
|
||||
repository=webhook.repository.full_name,
|
||||
branch=webhook.get_branch_name())
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to submit job to queue",
|
||||
job_name=job_name,
|
||||
error=str(e))
|
||||
return False
|
||||
|
||||
async def get_webhook_stats(self) -> Dict[str, Any]:
|
||||
"""获取 Webhook 处理统计"""
|
||||
try:
|
||||
# 获取队列统计
|
||||
queue_stats = await self._get_queue_stats()
|
||||
|
||||
# 获取防抖统计
|
||||
dedup_stats = await self.dedup_service.get_stats()
|
||||
|
||||
# 获取环境配置
|
||||
environments = {}
|
||||
for name, config in self.settings.environments.items():
|
||||
environments[name] = {
|
||||
"branches": config.branches,
|
||||
"jenkins_job": config.jenkins_job,
|
||||
"jenkins_url": config.jenkins_url,
|
||||
"priority": config.priority
|
||||
}
|
||||
|
||||
return {
|
||||
"queue": queue_stats,
|
||||
"deduplication": dedup_stats,
|
||||
"environments": environments,
|
||||
"config": {
|
||||
"max_concurrent": self.settings.queue.max_concurrent,
|
||||
"max_retries": self.settings.queue.max_retries,
|
||||
"retry_delay": self.settings.queue.retry_delay
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting webhook stats", error=str(e))
|
||||
return {"error": str(e)}
|
||||
|
||||
async def _get_queue_stats(self) -> Dict[str, Any]:
|
||||
"""获取队列统计信息"""
|
||||
try:
|
||||
# 获取 Celery 队列统计
|
||||
inspect = self.celery_app.control.inspect()
|
||||
|
||||
# 活跃任务
|
||||
active = inspect.active()
|
||||
active_count = sum(len(tasks) for tasks in active.values()) if active else 0
|
||||
|
||||
# 等待任务
|
||||
reserved = inspect.reserved()
|
||||
reserved_count = sum(len(tasks) for tasks in reserved.values()) if reserved else 0
|
||||
|
||||
# 注册的 worker
|
||||
registered = inspect.registered()
|
||||
worker_count = len(registered) if registered else 0
|
||||
|
||||
return {
|
||||
"active_tasks": active_count,
|
||||
"queued_tasks": reserved_count,
|
||||
"worker_count": worker_count,
|
||||
"queue_length": active_count + reserved_count
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error getting queue stats", error=str(e))
|
||||
return {"error": str(e)}
|
||||
|
||||
async def clear_queue(self) -> Dict[str, Any]:
|
||||
"""清空队列"""
|
||||
try:
|
||||
# 撤销所有活跃任务
|
||||
inspect = self.celery_app.control.inspect()
|
||||
active = inspect.active()
|
||||
|
||||
revoked_count = 0
|
||||
if active:
|
||||
for worker, tasks in active.items():
|
||||
for task in tasks:
|
||||
self.celery_app.control.revoke(task["id"], terminate=True)
|
||||
revoked_count += 1
|
||||
|
||||
logger.info("Queue cleared", revoked_count=revoked_count)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"revoked_count": revoked_count,
|
||||
"message": f"Cleared {revoked_count} tasks from queue"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error clearing queue", error=str(e))
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e)
|
||||
}
|
||||
373
apps/gitea-webhook-ambassador-python/app/static/js/dashboard.js
Normal file
373
apps/gitea-webhook-ambassador-python/app/static/js/dashboard.js
Normal file
@ -0,0 +1,373 @@
|
||||
// 全局变量存储 JWT 令牌
|
||||
let authToken = localStorage.getItem('auth_token');
|
||||
|
||||
$(document).ready(function() {
|
||||
// 检查认证状态
|
||||
if (!authToken) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置 AJAX 默认配置
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
// 不为登录请求添加认证头
|
||||
if (settings.url === '/api/auth/login') {
|
||||
return;
|
||||
}
|
||||
if (authToken) {
|
||||
xhr.setRequestHeader('Authorization', 'Bearer ' + authToken);
|
||||
}
|
||||
},
|
||||
error: function(xhr, status, error) {
|
||||
// 如果收到 401,重定向到登录页
|
||||
if (xhr.status === 401) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
handleAjaxError(xhr, status, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化工具提示
|
||||
$('[data-bs-toggle="tooltip"]').tooltip();
|
||||
|
||||
// 加载初始数据
|
||||
loadProjects();
|
||||
loadAPIKeys();
|
||||
loadLogs();
|
||||
checkHealth();
|
||||
loadHealthDetails();
|
||||
loadStatsDetails();
|
||||
|
||||
// 设置定期健康检查
|
||||
setInterval(checkHealth, 30000);
|
||||
|
||||
// 项目管理
|
||||
$('#addProjectForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const projectData = {
|
||||
name: $('#projectName').val(),
|
||||
jenkinsJob: $('#jenkinsJob').val(),
|
||||
giteaRepo: $('#giteaRepo').val()
|
||||
};
|
||||
|
||||
$.ajax({
|
||||
url: '/api/projects/',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(projectData),
|
||||
success: function() {
|
||||
$('#addProjectModal').modal('hide');
|
||||
$('#addProjectForm')[0].reset();
|
||||
loadProjects();
|
||||
showSuccess('项目添加成功');
|
||||
},
|
||||
error: handleAjaxError
|
||||
});
|
||||
});
|
||||
|
||||
// API 密钥管理
|
||||
$('#generateKeyForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
$.ajax({
|
||||
url: '/api/keys',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ description: $('#keyDescription').val() }),
|
||||
success: function(response) {
|
||||
$('#generateKeyModal').modal('hide');
|
||||
$('#generateKeyForm')[0].reset();
|
||||
loadAPIKeys();
|
||||
showSuccess('API 密钥生成成功');
|
||||
|
||||
// 显示新生成的密钥
|
||||
showApiKeyModal(response.key);
|
||||
},
|
||||
error: handleAjaxError
|
||||
});
|
||||
});
|
||||
|
||||
// 日志查询
|
||||
$('#logQueryForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
loadLogs({
|
||||
startTime: $('#startTime').val(),
|
||||
endTime: $('#endTime').val(),
|
||||
level: $('#logLevel').val(),
|
||||
query: $('#logQuery').val()
|
||||
});
|
||||
});
|
||||
|
||||
// 标签页切换
|
||||
$('.nav-link').on('click', function() {
|
||||
$('.nav-link').removeClass('active');
|
||||
$(this).addClass('active');
|
||||
});
|
||||
});
|
||||
|
||||
function loadProjects() {
|
||||
$.get('/api/projects/')
|
||||
.done(function(data) {
|
||||
const tbody = $('#projectsTable tbody');
|
||||
tbody.empty();
|
||||
|
||||
data.projects.forEach(function(project) {
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td>${escapeHtml(project.name)}</td>
|
||||
<td>${escapeHtml(project.jenkinsJob)}</td>
|
||||
<td>${escapeHtml(project.giteaRepo)}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="deleteProject(${project.id})">
|
||||
<i class="bi bi-trash"></i> 删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
})
|
||||
.fail(handleAjaxError);
|
||||
}
|
||||
|
||||
function loadAPIKeys() {
|
||||
$.get('/api/keys')
|
||||
.done(function(data) {
|
||||
const tbody = $('#apiKeysTable tbody');
|
||||
tbody.empty();
|
||||
|
||||
data.keys.forEach(function(key) {
|
||||
tbody.append(`
|
||||
<tr>
|
||||
<td>${escapeHtml(key.description || '无描述')}</td>
|
||||
<td><code class="api-key">${escapeHtml(key.key)}</code></td>
|
||||
<td>${new Date(key.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-danger" onclick="revokeKey(${key.id})">
|
||||
<i class="bi bi-trash"></i> 撤销
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
});
|
||||
})
|
||||
.fail(handleAjaxError);
|
||||
}
|
||||
|
||||
function loadLogs(query = {}) {
|
||||
$.get('/api/logs', query)
|
||||
.done(function(data) {
|
||||
const logContainer = $('#logEntries');
|
||||
logContainer.empty();
|
||||
|
||||
if (data.logs && data.logs.length > 0) {
|
||||
data.logs.forEach(function(log) {
|
||||
const levelClass = {
|
||||
'error': 'error',
|
||||
'warn': 'warn',
|
||||
'info': 'info',
|
||||
'debug': 'debug'
|
||||
}[log.level] || '';
|
||||
|
||||
logContainer.append(`
|
||||
<div class="log-entry ${levelClass}">
|
||||
<small>${new Date(log.timestamp).toLocaleString('zh-CN')}</small>
|
||||
[${escapeHtml(log.level.toUpperCase())}] ${escapeHtml(log.message)}
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
} else {
|
||||
logContainer.append('<div class="text-muted">暂无日志记录</div>');
|
||||
}
|
||||
})
|
||||
.fail(handleAjaxError);
|
||||
}
|
||||
|
||||
function checkHealth() {
|
||||
$.get('/health')
|
||||
.done(function(data) {
|
||||
const indicator = $('.health-indicator');
|
||||
indicator.removeClass('healthy unhealthy')
|
||||
.addClass(data.status === 'healthy' ? 'healthy' : 'unhealthy');
|
||||
$('#healthStatus').text(data.status === 'healthy' ? '健康' : '异常');
|
||||
})
|
||||
.fail(function() {
|
||||
const indicator = $('.health-indicator');
|
||||
indicator.removeClass('healthy').addClass('unhealthy');
|
||||
$('#healthStatus').text('异常');
|
||||
});
|
||||
}
|
||||
|
||||
function loadHealthDetails() {
|
||||
$.get('/health')
|
||||
.done(function(data) {
|
||||
const healthDetails = $('#healthDetails');
|
||||
healthDetails.html(`
|
||||
<div class="mb-3">
|
||||
<strong>状态:</strong>
|
||||
<span class="badge ${data.status === 'healthy' ? 'bg-success' : 'bg-danger'}">
|
||||
${data.status === 'healthy' ? '健康' : '异常'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>版本:</strong> ${data.version || '未知'}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>启动时间:</strong> ${data.uptime || '未知'}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>内存使用:</strong> ${data.memory || '未知'}
|
||||
</div>
|
||||
`);
|
||||
})
|
||||
.fail(function() {
|
||||
$('#healthDetails').html('<div class="text-danger">无法获取健康状态</div>');
|
||||
});
|
||||
}
|
||||
|
||||
function loadStatsDetails() {
|
||||
$.get('/api/stats')
|
||||
.done(function(data) {
|
||||
const statsDetails = $('#statsDetails');
|
||||
statsDetails.html(`
|
||||
<div class="mb-3">
|
||||
<strong>总项目数:</strong> ${data.total_projects || 0}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>API 密钥数:</strong> ${data.total_api_keys || 0}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>今日触发次数:</strong> ${data.today_triggers || 0}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>成功触发次数:</strong> ${data.successful_triggers || 0}
|
||||
</div>
|
||||
`);
|
||||
})
|
||||
.fail(function() {
|
||||
$('#statsDetails').html('<div class="text-danger">无法获取统计信息</div>');
|
||||
});
|
||||
}
|
||||
|
||||
function deleteProject(id) {
|
||||
if (!confirm('确定要删除这个项目吗?')) return;
|
||||
|
||||
$.ajax({
|
||||
url: `/api/projects/${id}`,
|
||||
method: 'DELETE',
|
||||
success: function() {
|
||||
loadProjects();
|
||||
showSuccess('项目删除成功');
|
||||
},
|
||||
error: handleAjaxError
|
||||
});
|
||||
}
|
||||
|
||||
function revokeKey(id) {
|
||||
if (!confirm('确定要撤销这个 API 密钥吗?')) return;
|
||||
|
||||
$.ajax({
|
||||
url: `/api/keys/${id}`,
|
||||
method: 'DELETE',
|
||||
success: function() {
|
||||
loadAPIKeys();
|
||||
showSuccess('API 密钥撤销成功');
|
||||
},
|
||||
error: handleAjaxError
|
||||
});
|
||||
}
|
||||
|
||||
function showApiKeyModal(key) {
|
||||
// 创建模态框显示新生成的密钥
|
||||
const modal = $(`
|
||||
<div class="modal fade" id="newApiKeyModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">新 API 密钥</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<strong>重要提示:</strong> 请保存这个密钥,因为它只会显示一次!
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API 密钥:</label>
|
||||
<input type="text" class="form-control" value="${key}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
|
||||
<button type="button" class="btn btn-primary" onclick="copyToClipboard('${key}')">
|
||||
复制到剪贴板
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$('body').append(modal);
|
||||
modal.modal('show');
|
||||
|
||||
modal.on('hidden.bs.modal', function() {
|
||||
modal.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
showSuccess('已复制到剪贴板');
|
||||
}, function() {
|
||||
showError('复制失败');
|
||||
});
|
||||
}
|
||||
|
||||
function handleAjaxError(jqXHR, textStatus, errorThrown) {
|
||||
const message = jqXHR.responseJSON?.detail || errorThrown || '发生错误';
|
||||
showError(`错误: ${message}`);
|
||||
}
|
||||
|
||||
function showSuccess(message) {
|
||||
// 创建成功提示
|
||||
const alert = $(`
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$('.main-content').prepend(alert);
|
||||
|
||||
// 3秒后自动消失
|
||||
setTimeout(function() {
|
||||
alert.alert('close');
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
// 创建错误提示
|
||||
const alert = $(`
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
${message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$('.main-content').prepend(alert);
|
||||
|
||||
// 5秒后自动消失
|
||||
setTimeout(function() {
|
||||
alert.alert('close');
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
306
apps/gitea-webhook-ambassador-python/app/tasks/jenkins_tasks.py
Normal file
306
apps/gitea-webhook-ambassador-python/app/tasks/jenkins_tasks.py
Normal file
@ -0,0 +1,306 @@
|
||||
"""
|
||||
Jenkins 任务处理
|
||||
使用 Celery 处理异步 Jenkins 任务触发
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict, Any
|
||||
from datetime import datetime
|
||||
import structlog
|
||||
from celery import Celery, Task
|
||||
import httpx
|
||||
|
||||
from app.config import get_settings
|
||||
from app.services.jenkins_service import JenkinsService
|
||||
|
||||
logger = structlog.get_logger()
|
||||
settings = get_settings()
|
||||
|
||||
# 创建 Celery 应用
|
||||
celery_app = Celery(
|
||||
"gitea_webhook_ambassador",
|
||||
broker=settings.redis.url,
|
||||
backend=settings.redis.url,
|
||||
include=["app.tasks.jenkins_tasks"]
|
||||
)
|
||||
|
||||
# Celery 配置
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
task_track_started=True,
|
||||
task_time_limit=300, # 5分钟超时
|
||||
task_soft_time_limit=240, # 4分钟软超时
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_max_tasks_per_child=1000,
|
||||
worker_max_memory_per_child=200000, # 200MB
|
||||
task_acks_late=True,
|
||||
task_reject_on_worker_lost=True,
|
||||
task_always_eager=False, # 生产环境设为 False
|
||||
result_expires=3600, # 结果缓存1小时
|
||||
)
|
||||
|
||||
|
||||
class JenkinsTask(Task):
|
||||
"""Jenkins 任务基类"""
|
||||
|
||||
abstract = True
|
||||
|
||||
def __init__(self):
|
||||
self.jenkins_service = None
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
if self.jenkins_service is None:
|
||||
self.jenkins_service = JenkinsService()
|
||||
return self.run(*args, **kwargs)
|
||||
|
||||
def on_failure(self, exc, task_id, args, kwargs, einfo):
|
||||
"""任务失败回调"""
|
||||
logger.error("Task failed",
|
||||
task_id=task_id,
|
||||
task_name=self.name,
|
||||
error=str(exc),
|
||||
args=args,
|
||||
kwargs=kwargs)
|
||||
|
||||
def on_retry(self, exc, task_id, args, kwargs, einfo):
|
||||
"""任务重试回调"""
|
||||
logger.warning("Task retrying",
|
||||
task_id=task_id,
|
||||
task_name=self.name,
|
||||
error=str(exc),
|
||||
retry_count=self.request.retries)
|
||||
|
||||
def on_success(self, retval, task_id, args, kwargs):
|
||||
"""任务成功回调"""
|
||||
logger.info("Task completed successfully",
|
||||
task_id=task_id,
|
||||
task_name=self.name,
|
||||
result=retval)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
base=JenkinsTask,
|
||||
max_retries=3,
|
||||
default_retry_delay=60,
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
retry_jitter=True
|
||||
)
|
||||
def trigger_jenkins_job(
|
||||
self,
|
||||
job_name: str,
|
||||
jenkins_url: str,
|
||||
parameters: Dict[str, str],
|
||||
event_id: str,
|
||||
repository: str,
|
||||
branch: str,
|
||||
commit_hash: str,
|
||||
priority: int = 1
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
触发 Jenkins 任务
|
||||
|
||||
Args:
|
||||
job_name: Jenkins 任务名
|
||||
jenkins_url: Jenkins URL
|
||||
parameters: 任务参数
|
||||
event_id: 事件 ID
|
||||
repository: 仓库名
|
||||
branch: 分支名
|
||||
commit_hash: 提交哈希
|
||||
priority: 优先级
|
||||
|
||||
Returns:
|
||||
Dict: 任务执行结果
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
logger.info("Starting Jenkins job trigger",
|
||||
task_id=self.request.id,
|
||||
job_name=job_name,
|
||||
jenkins_url=jenkins_url,
|
||||
repository=repository,
|
||||
branch=branch,
|
||||
commit_hash=commit_hash,
|
||||
priority=priority)
|
||||
|
||||
# 创建 Jenkins 服务实例
|
||||
jenkins_service = JenkinsService()
|
||||
|
||||
# 触发 Jenkins 任务
|
||||
result = asyncio.run(jenkins_service.trigger_job(
|
||||
job_name=job_name,
|
||||
jenkins_url=jenkins_url,
|
||||
parameters=parameters
|
||||
))
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
if result["success"]:
|
||||
logger.info("Jenkins job triggered successfully",
|
||||
task_id=self.request.id,
|
||||
job_name=job_name,
|
||||
build_number=result.get("build_number"),
|
||||
execution_time=execution_time)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"task_id": self.request.id,
|
||||
"job_name": job_name,
|
||||
"jenkins_url": jenkins_url,
|
||||
"build_number": result.get("build_number"),
|
||||
"build_url": result.get("build_url"),
|
||||
"event_id": event_id,
|
||||
"repository": repository,
|
||||
"branch": branch,
|
||||
"commit_hash": commit_hash,
|
||||
"execution_time": execution_time,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
else:
|
||||
logger.error("Jenkins job trigger failed",
|
||||
task_id=self.request.id,
|
||||
job_name=job_name,
|
||||
error=result.get("error"),
|
||||
execution_time=execution_time)
|
||||
|
||||
# 重试任务
|
||||
raise self.retry(
|
||||
countdown=settings.queue.retry_delay * (2 ** self.request.retries),
|
||||
max_retries=settings.queue.max_retries
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
execution_time = time.time() - start_time
|
||||
logger.error("Unexpected error in Jenkins task",
|
||||
task_id=self.request.id,
|
||||
job_name=job_name,
|
||||
error=str(e),
|
||||
execution_time=execution_time)
|
||||
|
||||
# 重试任务
|
||||
raise self.retry(
|
||||
countdown=settings.queue.retry_delay * (2 ** self.request.retries),
|
||||
max_retries=settings.queue.max_retries
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
base=JenkinsTask,
|
||||
max_retries=2,
|
||||
default_retry_delay=30
|
||||
)
|
||||
def check_jenkins_health(
|
||||
self,
|
||||
jenkins_url: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
检查 Jenkins 健康状态
|
||||
|
||||
Args:
|
||||
jenkins_url: Jenkins URL
|
||||
|
||||
Returns:
|
||||
Dict: 健康检查结果
|
||||
"""
|
||||
try:
|
||||
logger.info("Checking Jenkins health", jenkins_url=jenkins_url)
|
||||
|
||||
jenkins_service = JenkinsService()
|
||||
result = asyncio.run(jenkins_service.check_health(jenkins_url))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"jenkins_url": jenkins_url,
|
||||
"healthy": result.get("healthy", False),
|
||||
"response_time": result.get("response_time"),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Jenkins health check failed",
|
||||
jenkins_url=jenkins_url,
|
||||
error=str(e))
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"jenkins_url": jenkins_url,
|
||||
"error": str(e),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
@celery_app.task(
|
||||
bind=True,
|
||||
base=JenkinsTask
|
||||
)
|
||||
def cleanup_expired_tasks(self) -> Dict[str, Any]:
|
||||
"""
|
||||
清理过期任务
|
||||
|
||||
Returns:
|
||||
Dict: 清理结果
|
||||
"""
|
||||
try:
|
||||
logger.info("Starting task cleanup")
|
||||
|
||||
# 获取所有任务
|
||||
inspect = self.app.control.inspect()
|
||||
|
||||
# 清理过期的结果
|
||||
cleaned_count = 0
|
||||
current_time = time.time()
|
||||
|
||||
# 这里可以添加更复杂的清理逻辑
|
||||
# 比如清理超过一定时间的任务结果
|
||||
|
||||
logger.info("Task cleanup completed", cleaned_count=cleaned_count)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"cleaned_count": cleaned_count,
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Task cleanup failed", error=str(e))
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
# 定时任务
|
||||
@celery_app.on_after_configure.connect
|
||||
def setup_periodic_tasks(sender, **kwargs):
|
||||
"""设置定时任务"""
|
||||
|
||||
# 每小时清理过期任务
|
||||
sender.add_periodic_task(
|
||||
3600.0, # 1小时
|
||||
cleanup_expired_tasks.s(),
|
||||
name="cleanup-expired-tasks"
|
||||
)
|
||||
|
||||
# 每5分钟检查 Jenkins 健康状态
|
||||
for env_name, env_config in settings.environments.items():
|
||||
sender.add_periodic_task(
|
||||
300.0, # 5分钟
|
||||
check_jenkins_health.s(env_config.jenkins_url),
|
||||
name=f"check-jenkins-health-{env_name}"
|
||||
)
|
||||
|
||||
|
||||
def get_celery_app() -> Celery:
|
||||
"""获取 Celery 应用实例"""
|
||||
return celery_app
|
||||
@ -0,0 +1,326 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>仪表板 - Gitea Webhook Ambassador</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
|
||||
<style>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
padding: 48px 0 0;
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||
}
|
||||
.sidebar-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
height: calc(100vh - 48px);
|
||||
padding-top: .5rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.navbar-brand {
|
||||
padding-top: .75rem;
|
||||
padding-bottom: .75rem;
|
||||
font-size: 1rem;
|
||||
background-color: rgba(0, 0, 0, .25);
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .25);
|
||||
}
|
||||
.health-indicator {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.health-indicator.healthy {
|
||||
background-color: #28a745;
|
||||
}
|
||||
.health-indicator.unhealthy {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
.nav-link {
|
||||
color: #333;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 0.125rem 0;
|
||||
}
|
||||
.nav-link:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.nav-link.active {
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
}
|
||||
.tab-content {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
.api-key {
|
||||
font-family: monospace;
|
||||
background-color: #f8f9fa;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.log-entry {
|
||||
padding: 0.5rem;
|
||||
border-bottom: 1px solid #dee2e6;
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.log-entry:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.log-entry.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.log-entry.warn {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.log-entry.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
.log-entry.debug {
|
||||
background-color: #e2e3e5;
|
||||
color: #383d41;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: 240px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="navbar navbar-dark sticky-top bg-dark flex-md-nowrap p-0 shadow">
|
||||
<a class="navbar-brand col-md-3 col-lg-2 me-0 px-3" href="#">
|
||||
🔗 Gitea Webhook Ambassador
|
||||
</a>
|
||||
<div class="navbar-nav">
|
||||
<div class="nav-item text-nowrap">
|
||||
<span class="px-3 text-white">
|
||||
<span class="health-indicator"></span>
|
||||
<span id="healthStatus">检查中...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav id="sidebarMenu" class="col-md-3 col-lg-2 d-md-block bg-light sidebar collapse">
|
||||
<div class="position-sticky pt-3">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="#projects" data-bs-toggle="tab">
|
||||
<i class="bi bi-folder"></i> 项目管理
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#api-keys" data-bs-toggle="tab">
|
||||
<i class="bi bi-key"></i> API 密钥
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#logs" data-bs-toggle="tab">
|
||||
<i class="bi bi-journal-text"></i> 日志查看
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="#health" data-bs-toggle="tab">
|
||||
<i class="bi bi-heart-pulse"></i> 健康状态
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
|
||||
<div class="tab-content" id="myTabContent">
|
||||
<!-- 项目管理 Tab -->
|
||||
<div class="tab-pane fade show active" id="projects">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">项目管理</h1>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#addProjectModal">
|
||||
<i class="bi bi-plus"></i> 添加项目
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped" id="projectsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>项目名称</th>
|
||||
<th>Jenkins 任务</th>
|
||||
<th>Gitea 仓库</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API 密钥 Tab -->
|
||||
<div class="tab-pane fade" id="api-keys">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">API 密钥管理</h1>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#generateKeyModal">
|
||||
<i class="bi bi-plus"></i> 生成新密钥
|
||||
</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped" id="apiKeysTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>描述</th>
|
||||
<th>密钥</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志查看 Tab -->
|
||||
<div class="tab-pane fade" id="logs">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">日志查看</h1>
|
||||
</div>
|
||||
<form id="logQueryForm" class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<label for="startTime" class="form-label">开始时间</label>
|
||||
<input type="datetime-local" class="form-control" id="startTime">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="endTime" class="form-label">结束时间</label>
|
||||
<input type="datetime-local" class="form-control" id="endTime">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="logLevel" class="form-label">日志级别</label>
|
||||
<select class="form-select" id="logLevel">
|
||||
<option value="">全部</option>
|
||||
<option value="error">错误</option>
|
||||
<option value="warn">警告</option>
|
||||
<option value="info">信息</option>
|
||||
<option value="debug">调试</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="logQuery" class="form-label">搜索关键词</label>
|
||||
<input type="text" class="form-control" id="logQuery" placeholder="搜索日志...">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label"> </label>
|
||||
<button type="submit" class="btn btn-primary w-100">搜索</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="logEntries" class="border rounded p-3 bg-light" style="max-height: 500px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 健康状态 Tab -->
|
||||
<div class="tab-pane fade" id="health">
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">健康状态</h1>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">服务状态</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="healthDetails"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">统计信息</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="statsDetails"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加项目模态框 -->
|
||||
<div class="modal fade" id="addProjectModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">添加新项目</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="addProjectForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="projectName" class="form-label">项目名称</label>
|
||||
<input type="text" class="form-control" id="projectName" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="jenkinsJob" class="form-label">Jenkins 任务</label>
|
||||
<input type="text" class="form-control" id="jenkinsJob" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="giteaRepo" class="form-label">Gitea 仓库</label>
|
||||
<input type="text" class="form-control" id="giteaRepo" placeholder="owner/repo" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="submit" class="btn btn-primary">添加项目</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成 API 密钥模态框 -->
|
||||
<div class="modal fade" id="generateKeyModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">生成新 API 密钥</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form id="generateKeyForm">
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="keyDescription" class="form-label">密钥描述</label>
|
||||
<input type="text" class="form-control" id="keyDescription" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="submit" class="btn btn-primary">生成密钥</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/static/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
174
apps/gitea-webhook-ambassador-python/app/templates/login.html
Normal file
174
apps/gitea-webhook-ambassador-python/app/templates/login.html
Normal file
@ -0,0 +1,174 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - Gitea Webhook Ambassador</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 2rem;
|
||||
margin: auto;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.login-header h1 {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.login-header p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
.form-floating {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.btn-login:hover {
|
||||
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-form">
|
||||
<div class="login-header">
|
||||
<h1>🔗 Gitea Webhook Ambassador</h1>
|
||||
<p>高性能的 Gitea 到 Jenkins 的 Webhook 服务</p>
|
||||
</div>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="alert alert-danger" role="alert" id="loginError" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div class="form-floating">
|
||||
<input type="password" class="form-control" id="secret_key" name="secret_key"
|
||||
placeholder="管理员密钥" required>
|
||||
<label for="secret_key">管理员密钥</label>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-login" type="submit">
|
||||
<span id="loginBtnText">登录</span>
|
||||
<span id="loginBtnSpinner" class="spinner-border spinner-border-sm" style="display: none;"></span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<small class="text-muted">
|
||||
使用管理员密钥进行身份验证
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
// 检查是否已登录
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (token) {
|
||||
window.location.href = '/dashboard';
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查 URL 参数中的 secret_key
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const secretKeyFromUrl = urlParams.get('secret_key');
|
||||
if (secretKeyFromUrl) {
|
||||
$('#secret_key').val(secretKeyFromUrl);
|
||||
// 自动提交登录
|
||||
$('#loginForm').submit();
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理登录表单提交
|
||||
$('#loginForm').on('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const secretKey = $('#secret_key').val();
|
||||
if (!secretKey) {
|
||||
showError('请输入管理员密钥');
|
||||
return;
|
||||
}
|
||||
|
||||
// 显示加载状态
|
||||
$('#loginBtnText').hide();
|
||||
$('#loginBtnSpinner').show();
|
||||
$('#loginError').hide();
|
||||
|
||||
// 发送登录请求
|
||||
$.ajax({
|
||||
url: '/api/auth/login',
|
||||
method: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ secret_key: secretKey }),
|
||||
success: function(response) {
|
||||
if (response && response.token) {
|
||||
// 保存令牌并跳转
|
||||
localStorage.setItem('auth_token', response.token);
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
showError('服务器响应无效');
|
||||
}
|
||||
},
|
||||
error: function(xhr) {
|
||||
console.error('登录错误:', xhr);
|
||||
let errorMsg = '登录失败,请重试';
|
||||
|
||||
if (xhr.responseJSON && xhr.responseJSON.detail) {
|
||||
errorMsg = xhr.responseJSON.detail;
|
||||
}
|
||||
|
||||
showError(errorMsg);
|
||||
$('#secret_key').val('').focus();
|
||||
},
|
||||
complete: function() {
|
||||
// 恢复按钮状态
|
||||
$('#loginBtnText').show();
|
||||
$('#loginBtnSpinner').hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function showError(message) {
|
||||
$('#loginError').text(message).show();
|
||||
}
|
||||
|
||||
// 回车键提交
|
||||
$('#secret_key').on('keypress', function(e) {
|
||||
if (e.which === 13) {
|
||||
$('#loginForm').submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
133
apps/gitea-webhook-ambassador-python/check_version.sh
Executable file
133
apps/gitea-webhook-ambassador-python/check_version.sh
Executable file
@ -0,0 +1,133 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Gitea Webhook Ambassador - 版本检查脚本
|
||||
# 用于区分 Go 版本和 Python 版本
|
||||
|
||||
echo "🔍 检查 Gitea Webhook Ambassador 版本..."
|
||||
|
||||
# 检查端口 8000 (Python 版本默认端口)
|
||||
echo "📡 检查端口 8000 (Python 版本)..."
|
||||
if lsof -i :8000 > /dev/null 2>&1; then
|
||||
PID=$(lsof -ti :8000)
|
||||
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
|
||||
echo "✅ 端口 8000 被占用 (PID: $PID, 进程: $PROCESS)"
|
||||
|
||||
# 检查是否是 Python 进程
|
||||
if echo "$PROCESS" | grep -q "python\|uvicorn"; then
|
||||
echo "🐍 检测到 Python 版本正在运行"
|
||||
|
||||
# 尝试访问 Python 版本的 API
|
||||
if curl -s http://localhost:8000/api/health > /dev/null 2>&1; then
|
||||
echo "✅ Python 版本 API 响应正常"
|
||||
echo "🌐 访问地址: http://localhost:8000"
|
||||
echo "📊 仪表板: http://localhost:8000/dashboard"
|
||||
else
|
||||
echo "⚠️ Python 版本进程存在但 API 无响应"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 端口 8000 被其他进程占用"
|
||||
fi
|
||||
else
|
||||
echo "❌ 端口 8000 未被占用 (Python 版本未运行)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 检查端口 8080 (Go 版本默认端口)
|
||||
echo "📡 检查端口 8080 (Go 版本)..."
|
||||
if lsof -i :8080 > /dev/null 2>&1; then
|
||||
PID=$(lsof -ti :8080)
|
||||
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
|
||||
echo "✅ 端口 8080 被占用 (PID: $PID, 进程: $PROCESS)"
|
||||
|
||||
# 检查是否是 Go 进程
|
||||
if echo "$PROCESS" | grep -q "gitea-webhook-ambassador"; then
|
||||
echo "🚀 检测到 Go 版本正在运行"
|
||||
|
||||
# 尝试访问 Go 版本的 API
|
||||
if curl -s http://localhost:8080/health > /dev/null 2>&1; then
|
||||
echo "✅ Go 版本 API 响应正常"
|
||||
echo "🌐 访问地址: http://localhost:8080"
|
||||
else
|
||||
echo "⚠️ Go 版本进程存在但 API 无响应"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 端口 8080 被其他进程占用"
|
||||
fi
|
||||
else
|
||||
echo "❌ 端口 8080 未被占用 (Go 版本未运行)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 检查 PID 文件
|
||||
echo "📁 检查 PID 文件..."
|
||||
|
||||
# Python 版本 PID 文件
|
||||
PYTHON_PID_FILE="/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador-python/service.pid"
|
||||
if [ -f "$PYTHON_PID_FILE" ]; then
|
||||
PYTHON_PID=$(cat "$PYTHON_PID_FILE")
|
||||
if ps -p $PYTHON_PID > /dev/null 2>&1; then
|
||||
echo "✅ Python 版本 PID 文件存在 (PID: $PYTHON_PID)"
|
||||
else
|
||||
echo "⚠️ Python 版本 PID 文件存在但进程不存在"
|
||||
fi
|
||||
else
|
||||
echo "❌ Python 版本 PID 文件不存在"
|
||||
fi
|
||||
|
||||
# Go 版本 PID 文件 (如果存在)
|
||||
GO_PID_FILE="/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador/service.pid"
|
||||
if [ -f "$GO_PID_FILE" ]; then
|
||||
GO_PID=$(cat "$GO_PID_FILE")
|
||||
if ps -p $GO_PID > /dev/null 2>&1; then
|
||||
echo "✅ Go 版本 PID 文件存在 (PID: $GO_PID)"
|
||||
else
|
||||
echo "⚠️ Go 版本 PID 文件存在但进程不存在"
|
||||
fi
|
||||
else
|
||||
echo "❌ Go 版本 PID 文件不存在"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 总结
|
||||
echo "📊 总结:"
|
||||
echo "----------------------------------------"
|
||||
|
||||
PYTHON_RUNNING=false
|
||||
GO_RUNNING=false
|
||||
|
||||
# 检查 Python 版本
|
||||
if lsof -i :8000 > /dev/null 2>&1; then
|
||||
PID=$(lsof -ti :8000)
|
||||
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
|
||||
if echo "$PROCESS" | grep -q "python\|uvicorn"; then
|
||||
PYTHON_RUNNING=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查 Go 版本
|
||||
if lsof -i :8080 > /dev/null 2>&1; then
|
||||
PID=$(lsof -ti :8080)
|
||||
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
|
||||
if echo "$PROCESS" | grep -q "gitea-webhook-ambassador"; then
|
||||
GO_RUNNING=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$PYTHON_RUNNING" = true ] && [ "$GO_RUNNING" = true ]; then
|
||||
echo "⚠️ 两个版本都在运行!"
|
||||
echo "🐍 Python 版本: http://localhost:8000"
|
||||
echo "🚀 Go 版本: http://localhost:8080"
|
||||
elif [ "$PYTHON_RUNNING" = true ]; then
|
||||
echo "✅ 当前运行: Python 版本"
|
||||
echo "🌐 访问地址: http://localhost:8000"
|
||||
elif [ "$GO_RUNNING" = true ]; then
|
||||
echo "✅ 当前运行: Go 版本"
|
||||
echo "🌐 访问地址: http://localhost:8080"
|
||||
else
|
||||
echo "❌ 没有检测到任何版本在运行"
|
||||
fi
|
||||
|
||||
echo "----------------------------------------"
|
||||
@ -0,0 +1,39 @@
|
||||
# 环境分发配置
|
||||
environments:
|
||||
dev:
|
||||
branches: ["dev", "develop", "development", "feature/*"]
|
||||
jenkins_job: "alpha-build"
|
||||
jenkins_url: "https://jenkins-alpha.freeleaps.com"
|
||||
priority: 2
|
||||
|
||||
prod:
|
||||
branches: ["prod", "production", "main", "master", "release/*"]
|
||||
jenkins_job: "production-build"
|
||||
jenkins_url: "https://jenkins-prod.freeleaps.com"
|
||||
priority: 1
|
||||
|
||||
staging:
|
||||
branches: ["staging", "stage", "pre-prod"]
|
||||
jenkins_job: "staging-build"
|
||||
jenkins_url: "https://jenkins-staging.freeleaps.com"
|
||||
priority: 3
|
||||
|
||||
default:
|
||||
branches: ["*"]
|
||||
jenkins_job: "default-build"
|
||||
jenkins_url: "https://jenkins-default.freeleaps.com"
|
||||
priority: 4
|
||||
|
||||
# 防抖配置
|
||||
deduplication:
|
||||
enabled: true
|
||||
window_seconds: 300 # 5分钟防抖窗口
|
||||
strategy: "commit_branch" # commit_hash + branch
|
||||
cache_ttl: 3600 # 缓存1小时
|
||||
|
||||
# 队列配置
|
||||
queue:
|
||||
max_concurrent: 10
|
||||
max_retries: 3
|
||||
retry_delay: 60 # 秒
|
||||
priority_levels: 4
|
||||
32
apps/gitea-webhook-ambassador-python/configs/config.yaml
Normal file
32
apps/gitea-webhook-ambassador-python/configs/config.yaml
Normal file
@ -0,0 +1,32 @@
|
||||
server:
|
||||
port: 8000
|
||||
webhookPath: "/webhook"
|
||||
secretHeader: "X-Gitea-Signature"
|
||||
secretKey: "admin-secret-key-change-in-production"
|
||||
|
||||
jenkins:
|
||||
url: "http://jenkins.example.com"
|
||||
username: "jenkins-user"
|
||||
token: "jenkins-api-token"
|
||||
timeout: 30
|
||||
|
||||
admin:
|
||||
token: "admin-api-token" # Token for admin API access
|
||||
|
||||
database:
|
||||
path: "data/gitea-webhook-ambassador.db" # Path to SQLite database file
|
||||
|
||||
logging:
|
||||
level: "info" # debug, info, warn, error
|
||||
format: "text" # text, json
|
||||
file: "logs/service.log" # stdout if empty, or path to log file
|
||||
|
||||
worker:
|
||||
poolSize: 10 # Number of concurrent workers
|
||||
queueSize: 100 # Size of job queue
|
||||
maxRetries: 3 # Maximum number of retry attempts
|
||||
retryBackoff: 1 # Initial retry backoff in seconds (exponential)
|
||||
|
||||
eventCleanup:
|
||||
interval: 3600 # Cleanup interval in seconds
|
||||
expireAfter: 7200 # Event expiration time in seconds
|
||||
220
apps/gitea-webhook-ambassador-python/devbox
Executable file
220
apps/gitea-webhook-ambassador-python/devbox
Executable file
@ -0,0 +1,220 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Gitea Webhook Ambassador (Python) - Devbox Script
|
||||
# This script mimics the Go version's devbox functionality
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_NAME="gitea-webhook-ambassador"
|
||||
PID_FILE="$SCRIPT_DIR/service.pid"
|
||||
LOG_FILE="$SCRIPT_DIR/logs/service.log"
|
||||
|
||||
# Create logs directory
|
||||
mkdir -p "$SCRIPT_DIR/logs"
|
||||
|
||||
# Function to show usage
|
||||
show_usage() {
|
||||
echo "Usage: $0 {start|stop|restart|status|logs|follow|init|install|help}"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " start - Start the service in background"
|
||||
echo " stop - Stop the service"
|
||||
echo " restart - Restart the service"
|
||||
echo " status - Show service status"
|
||||
echo " logs - Show latest logs"
|
||||
echo " follow - Follow logs in real-time"
|
||||
echo " init - Initialize database"
|
||||
echo " install - Install dependencies"
|
||||
echo " help - Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 start # Start service"
|
||||
echo " $0 status # Check status"
|
||||
echo " $0 logs # View logs"
|
||||
}
|
||||
|
||||
# Function to check if virtual environment exists
|
||||
check_venv() {
|
||||
if [ ! -d "$SCRIPT_DIR/venv" ]; then
|
||||
echo "❌ Virtual environment not found. Run '$0 install' first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to activate virtual environment
|
||||
activate_venv() {
|
||||
source "$SCRIPT_DIR/venv/bin/activate"
|
||||
}
|
||||
|
||||
# Function to start service
|
||||
start_service() {
|
||||
echo "🚀 Starting $APP_NAME (Python Version)..."
|
||||
echo "🐍 Version: Python Enhanced with Web UI"
|
||||
|
||||
check_venv
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "❌ Service is already running (PID: $PID)"
|
||||
return 1
|
||||
else
|
||||
echo "⚠️ Found stale PID file, cleaning up..."
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Activate virtual environment and start service
|
||||
cd "$SCRIPT_DIR"
|
||||
activate_venv
|
||||
|
||||
# Start the service in background
|
||||
nohup python -m uvicorn app.main_enhanced:app --host 0.0.0.0 --port 8000 > "$LOG_FILE" 2>&1 &
|
||||
PID=$!
|
||||
echo $PID > "$PID_FILE"
|
||||
|
||||
# Wait a moment for service to start
|
||||
sleep 3
|
||||
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "✅ Python Service started successfully (PID: $PID)"
|
||||
echo "📝 Log file: $LOG_FILE"
|
||||
echo "🌐 Access: http://localhost:8000"
|
||||
echo "📊 Dashboard: http://localhost:8000/dashboard"
|
||||
echo "🔑 Admin key: admin-secret-key-change-in-production"
|
||||
echo "🐍 Python Version Features: Web UI, Database, JWT Auth"
|
||||
else
|
||||
echo "❌ Service failed to start"
|
||||
rm -f "$PID_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to stop service
|
||||
stop_service() {
|
||||
echo "🛑 Stopping $APP_NAME..."
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
kill $PID
|
||||
echo "✅ Service stopped (PID: $PID)"
|
||||
else
|
||||
echo "⚠️ Service not running"
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
else
|
||||
echo "⚠️ No PID file found"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to restart service
|
||||
restart_service() {
|
||||
echo "🔄 Restarting $APP_NAME..."
|
||||
stop_service
|
||||
sleep 2
|
||||
start_service
|
||||
}
|
||||
|
||||
# Function to show status
|
||||
show_status() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "✅ $APP_NAME (Python Version) is running (PID: $PID)"
|
||||
echo "🐍 Version: Python Enhanced with Web UI"
|
||||
echo "📝 Log file: $LOG_FILE"
|
||||
echo "🌐 Access: http://localhost:8000"
|
||||
echo "📊 Dashboard: http://localhost:8000/dashboard"
|
||||
else
|
||||
echo "❌ $APP_NAME is not running (PID file exists but process not found)"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
else
|
||||
echo "❌ $APP_NAME is not running"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to show logs
|
||||
show_logs() {
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
echo "📝 Latest logs (last 50 lines):"
|
||||
echo "----------------------------------------"
|
||||
tail -n 50 "$LOG_FILE"
|
||||
echo "----------------------------------------"
|
||||
echo "Full log file: $LOG_FILE"
|
||||
else
|
||||
echo "❌ No log file found"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to follow logs
|
||||
follow_logs() {
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
echo "📝 Following logs (Ctrl+C to exit):"
|
||||
tail -f "$LOG_FILE"
|
||||
else
|
||||
echo "❌ No log file found"
|
||||
fi
|
||||
}
|
||||
|
||||
# Function to initialize database
|
||||
init_database() {
|
||||
echo "🗄️ Initializing database..."
|
||||
check_venv
|
||||
cd "$SCRIPT_DIR"
|
||||
activate_venv
|
||||
python -c "from app.models.database import create_tables; create_tables(); print('Database initialized successfully')"
|
||||
}
|
||||
|
||||
# Function to install dependencies
|
||||
install_dependencies() {
|
||||
echo "📦 Installing dependencies..."
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
if [ -d "venv" ]; then
|
||||
echo "⚠️ Virtual environment already exists. Removing..."
|
||||
rm -rf venv
|
||||
fi
|
||||
|
||||
python3 -m venv venv
|
||||
activate_venv
|
||||
pip install -r requirements.txt
|
||||
echo "✅ Dependencies installed successfully"
|
||||
}
|
||||
|
||||
# Main logic
|
||||
case "$1" in
|
||||
start)
|
||||
start_service
|
||||
;;
|
||||
stop)
|
||||
stop_service
|
||||
;;
|
||||
restart)
|
||||
restart_service
|
||||
;;
|
||||
status)
|
||||
show_status
|
||||
;;
|
||||
logs)
|
||||
show_logs
|
||||
;;
|
||||
follow)
|
||||
follow_logs
|
||||
;;
|
||||
init)
|
||||
init_database
|
||||
;;
|
||||
install)
|
||||
install_dependencies
|
||||
;;
|
||||
help|--help|-h)
|
||||
show_usage
|
||||
;;
|
||||
*)
|
||||
echo "❌ Unknown command: $1"
|
||||
echo ""
|
||||
show_usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
176
apps/gitea-webhook-ambassador-python/docker-compose.yml
Normal file
176
apps/gitea-webhook-ambassador-python/docker-compose.yml
Normal file
@ -0,0 +1,176 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Redis 服务
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: webhook-ambassador-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# PostgreSQL 数据库 (可选,用于生产环境)
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: webhook-ambassador-postgres
|
||||
environment:
|
||||
POSTGRES_DB: webhook_ambassador
|
||||
POSTGRES_USER: webhook_user
|
||||
POSTGRES_PASSWORD: webhook_password
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U webhook_user -d webhook_ambassador"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Webhook Ambassador API 服务
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: webhook-ambassador-api
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- DATABASE_URL=postgresql://webhook_user:webhook_password@postgres:5432/webhook_ambassador
|
||||
- JENKINS_USERNAME=${JENKINS_USERNAME}
|
||||
- JENKINS_TOKEN=${JENKINS_TOKEN}
|
||||
- SECURITY_SECRET_KEY=${SECURITY_SECRET_KEY}
|
||||
- LOGGING_LEVEL=INFO
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
|
||||
# Celery Worker
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: webhook-ambassador-worker
|
||||
command: celery -A app.tasks.jenkins_tasks worker --loglevel=info --concurrency=4
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- DATABASE_URL=postgresql://webhook_user:webhook_password@postgres:5432/webhook_ambassador
|
||||
- JENKINS_USERNAME=${JENKINS_USERNAME}
|
||||
- JENKINS_TOKEN=${JENKINS_TOKEN}
|
||||
- SECURITY_SECRET_KEY=${SECURITY_SECRET_KEY}
|
||||
- LOGGING_LEVEL=INFO
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# Celery Beat (定时任务调度器)
|
||||
beat:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: webhook-ambassador-beat
|
||||
command: celery -A app.tasks.jenkins_tasks beat --loglevel=info
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- DATABASE_URL=postgresql://webhook_user:webhook_password@postgres:5432/webhook_ambassador
|
||||
- JENKINS_USERNAME=${JENKINS_USERNAME}
|
||||
- JENKINS_TOKEN=${JENKINS_TOKEN}
|
||||
- SECURITY_SECRET_KEY=${SECURITY_SECRET_KEY}
|
||||
- LOGGING_LEVEL=INFO
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# Flower (Celery 监控)
|
||||
flower:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: webhook-ambassador-flower
|
||||
command: celery -A app.tasks.jenkins_tasks flower --port=5555
|
||||
ports:
|
||||
- "5555:5555"
|
||||
environment:
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
- DATABASE_URL=postgresql://webhook_user:webhook_password@postgres:5432/webhook_ambassador
|
||||
- JENKINS_USERNAME=${JENKINS_USERNAME}
|
||||
- JENKINS_TOKEN=${JENKINS_TOKEN}
|
||||
- SECURITY_SECRET_KEY=${SECURITY_SECRET_KEY}
|
||||
- LOGGING_LEVEL=INFO
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
# Prometheus (监控)
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: webhook-ambassador-prometheus
|
||||
ports:
|
||||
- "9090:9090"
|
||||
volumes:
|
||||
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus_data:/prometheus
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.console.libraries=/etc/prometheus/console_libraries'
|
||||
- '--web.console.templates=/etc/prometheus/consoles'
|
||||
- '--storage.tsdb.retention.time=200h'
|
||||
- '--web.enable-lifecycle'
|
||||
restart: unless-stopped
|
||||
|
||||
# Grafana (监控面板)
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: webhook-ambassador-grafana
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
|
||||
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources
|
||||
depends_on:
|
||||
- prometheus
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
postgres_data:
|
||||
prometheus_data:
|
||||
grafana_data:
|
||||
42
apps/gitea-webhook-ambassador-python/env.example
Normal file
42
apps/gitea-webhook-ambassador-python/env.example
Normal file
@ -0,0 +1,42 @@
|
||||
# 应用配置
|
||||
APP_NAME=Gitea Webhook Ambassador
|
||||
DEBUG=false
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=sqlite:///./webhook_ambassador.db
|
||||
# 生产环境使用 PostgreSQL:
|
||||
# DATABASE_URL=postgresql://webhook_user:webhook_password@localhost:5432/webhook_ambassador
|
||||
|
||||
# Redis 配置
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# Jenkins 配置
|
||||
JENKINS_USERNAME=your_jenkins_username
|
||||
JENKINS_TOKEN=115127e693f1bc6b7194f58ff6d6283bd0
|
||||
JENKINS_TIMEOUT=30
|
||||
|
||||
# 安全配置
|
||||
SECURITY_SECRET_KEY=r6Y@QTb*7BQN@hDGsN
|
||||
SECURITY_WEBHOOK_SECRET_HEADER=X-Gitea-Signature
|
||||
SECURITY_RATE_LIMIT_PER_MINUTE=100
|
||||
|
||||
# 日志配置
|
||||
LOGGING_LEVEL=INFO
|
||||
LOGGING_FORMAT=json
|
||||
LOGGING_FILE=
|
||||
|
||||
# 队列配置
|
||||
QUEUE_MAX_CONCURRENT=10
|
||||
QUEUE_MAX_RETRIES=3
|
||||
QUEUE_RETRY_DELAY=60
|
||||
QUEUE_PRIORITY_LEVELS=3
|
||||
|
||||
# 防抖配置
|
||||
DEDUPLICATION_ENABLED=true
|
||||
DEDUPLICATION_WINDOW_SECONDS=300
|
||||
DEDUPLICATION_STRATEGY=commit_branch
|
||||
DEDUPLICATION_CACHE_TTL=3600
|
||||
41
apps/gitea-webhook-ambassador-python/fix_pid.sh
Executable file
41
apps/gitea-webhook-ambassador-python/fix_pid.sh
Executable file
@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 修复 PID 文件问题
|
||||
echo "🔧 修复 PID 文件问题..."
|
||||
|
||||
# 查找 Python 服务进程
|
||||
PID=$(lsof -ti :8000 2>/dev/null)
|
||||
|
||||
if [ -n "$PID" ]; then
|
||||
echo "✅ 找到运行中的 Python 服务 (PID: $PID)"
|
||||
|
||||
# 检查进程是否是我们的服务
|
||||
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
|
||||
if echo "$PROCESS" | grep -q "python"; then
|
||||
echo "🐍 确认是 Python 版本的 Gitea Webhook Ambassador"
|
||||
|
||||
# 创建 PID 文件
|
||||
echo $PID > service.pid
|
||||
echo "✅ 已创建 PID 文件: service.pid"
|
||||
|
||||
# 验证 PID 文件
|
||||
if [ -f "service.pid" ]; then
|
||||
STORED_PID=$(cat service.pid)
|
||||
echo "📝 PID 文件内容: $STORED_PID"
|
||||
|
||||
if [ "$STORED_PID" = "$PID" ]; then
|
||||
echo "✅ PID 文件修复成功"
|
||||
echo "💡 现在可以使用 './devbox stop' 来停止服务"
|
||||
else
|
||||
echo "❌ PID 文件内容不匹配"
|
||||
fi
|
||||
else
|
||||
echo "❌ 无法创建 PID 文件"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ 端口 8000 被其他进程占用"
|
||||
fi
|
||||
else
|
||||
echo "❌ 没有找到运行中的 Python 服务"
|
||||
echo "💡 请先启动服务: './devbox start'"
|
||||
fi
|
||||
@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Gitea Webhook Ambassador Python Service
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=nicolas
|
||||
WorkingDirectory=/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador-python
|
||||
Environment=PATH=/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador-python/venv/bin
|
||||
ExecStart=/home/nicolas/freeleaps-ops/apps/gitea-webhook-ambassador-python/venv/bin/python -m uvicorn app.main_demo:app --host 0.0.0.0 --port 8000
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
31
apps/gitea-webhook-ambassador-python/quick_check.sh
Executable file
31
apps/gitea-webhook-ambassador-python/quick_check.sh
Executable file
@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 快速检查当前运行的版本
|
||||
|
||||
echo "🔍 快速检查 Gitea Webhook Ambassador 版本..."
|
||||
|
||||
# 检查 Python 版本 (端口 8000)
|
||||
if lsof -i :8000 > /dev/null 2>&1; then
|
||||
PID=$(lsof -ti :8000)
|
||||
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
|
||||
if echo "$PROCESS" | grep -q "python\|uvicorn"; then
|
||||
echo "🐍 Python 版本正在运行 (PID: $PID)"
|
||||
echo "🌐 http://localhost:8000"
|
||||
echo "📊 http://localhost:8000/dashboard"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查 Go 版本 (端口 8080)
|
||||
if lsof -i :8080 > /dev/null 2>&1; then
|
||||
PID=$(lsof -ti :8080)
|
||||
PROCESS=$(ps -p $PID -o comm= 2>/dev/null)
|
||||
if echo "$PROCESS" | grep -q "gitea-webhook-ambassador"; then
|
||||
echo "🚀 Go 版本正在运行 (PID: $PID)"
|
||||
echo "🌐 http://localhost:8080"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "❌ 没有检测到任何版本在运行"
|
||||
echo "💡 使用 './devbox start' 启动 Python 版本"
|
||||
15
apps/gitea-webhook-ambassador-python/requirements.txt
Normal file
15
apps/gitea-webhook-ambassador-python/requirements.txt
Normal file
@ -0,0 +1,15 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
structlog==23.2.0
|
||||
httpx==0.25.2
|
||||
celery==5.3.4
|
||||
redis==5.0.1
|
||||
sqlalchemy==2.0.23
|
||||
jinja2==3.1.2
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
psutil==5.9.6
|
||||
aiofiles==23.2.1
|
||||
61
apps/gitea-webhook-ambassador-python/scripts/setup.sh
Executable file
61
apps/gitea-webhook-ambassador-python/scripts/setup.sh
Executable file
@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Gitea Webhook Ambassador 快速设置脚本
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 开始设置 Gitea Webhook Ambassador..."
|
||||
|
||||
# 检查 Python 版本
|
||||
python_version=$(python3 --version 2>&1 | grep -oE '[0-9]+\.[0-9]+')
|
||||
required_version="3.8"
|
||||
|
||||
if [ "$(printf '%s\n' "$required_version" "$python_version" | sort -V | head -n1)" != "$required_version" ]; then
|
||||
echo "❌ 需要 Python 3.8 或更高版本,当前版本: $python_version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Python 版本检查通过: $python_version"
|
||||
|
||||
# 创建虚拟环境
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "📦 创建虚拟环境..."
|
||||
python3 -m venv venv
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
echo "🔧 激活虚拟环境..."
|
||||
source venv/bin/activate
|
||||
|
||||
# 升级 pip
|
||||
echo "⬆️ 升级 pip..."
|
||||
pip install --upgrade pip
|
||||
|
||||
# 安装依赖
|
||||
echo "📚 安装依赖..."
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 创建配置文件
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "⚙️ 创建环境配置文件..."
|
||||
cp env.example .env
|
||||
echo "📝 请编辑 .env 文件,配置您的 Jenkins 凭据和其他设置"
|
||||
fi
|
||||
|
||||
# 创建日志目录
|
||||
mkdir -p logs
|
||||
|
||||
# 创建数据库目录
|
||||
mkdir -p data
|
||||
|
||||
echo "✅ 设置完成!"
|
||||
echo ""
|
||||
echo "📋 下一步操作:"
|
||||
echo "1. 编辑 .env 文件,配置 Jenkins 凭据"
|
||||
echo "2. 运行: source venv/bin/activate"
|
||||
echo "3. 启动 Redis: docker run -d -p 6379:6379 redis:alpine"
|
||||
echo "4. 启动服务: python -m uvicorn app.main:app --reload"
|
||||
echo "5. 启动 Celery worker: celery -A app.tasks.jenkins_tasks worker --loglevel=info"
|
||||
echo ""
|
||||
echo "🌐 访问地址: http://localhost:8000"
|
||||
echo "📊 监控面板: http://localhost:8000/health"
|
||||
64
apps/gitea-webhook-ambassador-python/scripts/start.sh
Executable file
64
apps/gitea-webhook-ambassador-python/scripts/start.sh
Executable file
@ -0,0 +1,64 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Gitea Webhook Ambassador 启动脚本
|
||||
|
||||
set -e
|
||||
|
||||
# 检查虚拟环境
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "❌ 虚拟环境不存在,请先运行 ./scripts/setup.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 激活虚拟环境
|
||||
source venv/bin/activate
|
||||
|
||||
# 检查环境文件
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "❌ .env 文件不存在,请先运行 ./scripts/setup.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查 Redis 是否运行
|
||||
if ! docker ps | grep -q redis; then
|
||||
echo "🐳 启动 Redis..."
|
||||
docker run -d --name webhook-redis -p 6379:6379 redis:alpine
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
echo "🚀 启动 Gitea Webhook Ambassador..."
|
||||
|
||||
# 启动 API 服务
|
||||
echo "🌐 启动 API 服务..."
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload &
|
||||
API_PID=$!
|
||||
|
||||
# 等待 API 服务启动
|
||||
sleep 5
|
||||
|
||||
# 启动 Celery worker
|
||||
echo "⚙️ 启动 Celery worker..."
|
||||
celery -A app.tasks.jenkins_tasks worker --loglevel=info --concurrency=4 &
|
||||
WORKER_PID=$!
|
||||
|
||||
# 启动 Celery beat (定时任务)
|
||||
echo "⏰ 启动定时任务调度器..."
|
||||
celery -A app.tasks.jenkins_tasks beat --loglevel=info &
|
||||
BEAT_PID=$!
|
||||
|
||||
echo "✅ 所有服务已启动!"
|
||||
echo ""
|
||||
echo "📊 服务状态:"
|
||||
echo "- API 服务: http://localhost:8000 (PID: $API_PID)"
|
||||
echo "- 健康检查: http://localhost:8000/health"
|
||||
echo "- 监控指标: http://localhost:8000/metrics"
|
||||
echo "- Celery Worker: PID $WORKER_PID"
|
||||
echo "- Celery Beat: PID $BEAT_PID"
|
||||
echo ""
|
||||
echo "🛑 按 Ctrl+C 停止所有服务"
|
||||
|
||||
# 等待中断信号
|
||||
trap 'echo "🛑 正在停止服务..."; kill $API_PID $WORKER_PID $BEAT_PID 2>/dev/null; exit 0' INT
|
||||
|
||||
# 等待所有后台进程
|
||||
wait
|
||||
154
apps/gitea-webhook-ambassador-python/start.sh
Executable file
154
apps/gitea-webhook-ambassador-python/start.sh
Executable file
@ -0,0 +1,154 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Gitea Webhook Ambassador Python 启动脚本
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SERVICE_NAME="gitea-webhook-ambassador-python"
|
||||
LOG_FILE="$SCRIPT_DIR/logs/service.log"
|
||||
PID_FILE="$SCRIPT_DIR/service.pid"
|
||||
|
||||
# 创建日志目录
|
||||
mkdir -p "$SCRIPT_DIR/logs"
|
||||
|
||||
# 激活虚拟环境
|
||||
source "$SCRIPT_DIR/venv/bin/activate"
|
||||
|
||||
# 函数:启动服务
|
||||
start_service() {
|
||||
echo "🚀 启动 $SERVICE_NAME..."
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "❌ 服务已在运行 (PID: $PID)"
|
||||
return 1
|
||||
else
|
||||
echo "⚠️ 发现过期的 PID 文件,正在清理..."
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 后台启动服务
|
||||
nohup python -m uvicorn app.main_demo:app --host 0.0.0.0 --port 8000 > "$LOG_FILE" 2>&1 &
|
||||
PID=$!
|
||||
echo $PID > "$PID_FILE"
|
||||
|
||||
# 等待服务启动
|
||||
sleep 3
|
||||
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "✅ 服务启动成功 (PID: $PID)"
|
||||
echo "📝 日志文件: $LOG_FILE"
|
||||
echo "🌐 访问地址: http://localhost:8000"
|
||||
echo "🔑 演示密钥: demo_admin_key, demo_user_key"
|
||||
else
|
||||
echo "❌ 服务启动失败"
|
||||
rm -f "$PID_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:停止服务
|
||||
stop_service() {
|
||||
echo "🛑 停止 $SERVICE_NAME..."
|
||||
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
kill $PID
|
||||
echo "✅ 服务已停止 (PID: $PID)"
|
||||
else
|
||||
echo "⚠️ 服务未运行"
|
||||
fi
|
||||
rm -f "$PID_FILE"
|
||||
else
|
||||
echo "⚠️ PID 文件不存在"
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:重启服务
|
||||
restart_service() {
|
||||
echo "🔄 重启 $SERVICE_NAME..."
|
||||
stop_service
|
||||
sleep 2
|
||||
start_service
|
||||
}
|
||||
|
||||
# 函数:查看状态
|
||||
status_service() {
|
||||
if [ -f "$PID_FILE" ]; then
|
||||
PID=$(cat "$PID_FILE")
|
||||
if ps -p $PID > /dev/null 2>&1; then
|
||||
echo "✅ $SERVICE_NAME 正在运行 (PID: $PID)"
|
||||
echo "📝 日志文件: $LOG_FILE"
|
||||
echo "🌐 访问地址: http://localhost:8000"
|
||||
else
|
||||
echo "❌ $SERVICE_NAME 未运行 (PID 文件存在但进程不存在)"
|
||||
rm -f "$PID_FILE"
|
||||
fi
|
||||
else
|
||||
echo "❌ $SERVICE_NAME 未运行"
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:查看日志
|
||||
show_logs() {
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
echo "📝 显示最新日志 (最后 50 行):"
|
||||
echo "----------------------------------------"
|
||||
tail -n 50 "$LOG_FILE"
|
||||
echo "----------------------------------------"
|
||||
echo "完整日志文件: $LOG_FILE"
|
||||
else
|
||||
echo "❌ 日志文件不存在"
|
||||
fi
|
||||
}
|
||||
|
||||
# 函数:实时日志
|
||||
follow_logs() {
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
echo "📝 实时日志 (按 Ctrl+C 退出):"
|
||||
tail -f "$LOG_FILE"
|
||||
else
|
||||
echo "❌ 日志文件不存在"
|
||||
fi
|
||||
}
|
||||
|
||||
# 主逻辑
|
||||
case "$1" in
|
||||
start)
|
||||
start_service
|
||||
;;
|
||||
stop)
|
||||
stop_service
|
||||
;;
|
||||
restart)
|
||||
restart_service
|
||||
;;
|
||||
status)
|
||||
status_service
|
||||
;;
|
||||
logs)
|
||||
show_logs
|
||||
;;
|
||||
follow)
|
||||
follow_logs
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {start|stop|restart|status|logs|follow}"
|
||||
echo ""
|
||||
echo "命令说明:"
|
||||
echo " start - 启动服务"
|
||||
echo " stop - 停止服务"
|
||||
echo " restart - 重启服务"
|
||||
echo " status - 查看服务状态"
|
||||
echo " logs - 查看最新日志"
|
||||
echo " follow - 实时查看日志"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 start # 启动服务"
|
||||
echo " $0 status # 查看状态"
|
||||
echo " $0 logs # 查看日志"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
172
apps/gitea-webhook-ambassador-python/test_auth.py
Normal file
172
apps/gitea-webhook-ambassador-python/test_auth.py
Normal file
@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
认证功能测试脚本
|
||||
演示如何正确使用 JWT 和 API 密钥认证
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
async def test_jwt_authentication():
|
||||
"""测试 JWT 认证"""
|
||||
print("🔐 测试 JWT 认证")
|
||||
print("-" * 50)
|
||||
|
||||
# 注意:在实际应用中,JWT 令牌应该通过登录端点获取
|
||||
# 这里我们使用一个示例令牌(在实际环境中需要从登录端点获取)
|
||||
|
||||
# 模拟 JWT 令牌(实际应用中应该从登录端点获取)
|
||||
jwt_token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZG1pbiIsImV4cCI6MTczMjAwMDAwMH0.test"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 使用 JWT 令牌访问管理端点
|
||||
headers = {"Authorization": f"Bearer {jwt_token}"}
|
||||
|
||||
# 测试访问日志端点
|
||||
async with session.get(f"{BASE_URL}/api/logs", headers=headers) as response:
|
||||
if response.status == 200:
|
||||
logs = await response.json()
|
||||
print("✅ JWT 认证成功 - 日志访问")
|
||||
print(f" 获取到 {len(logs)} 条日志")
|
||||
else:
|
||||
print(f"❌ JWT 认证失败 - 日志访问: {response.status}")
|
||||
if response.status == 401:
|
||||
print(" 原因: JWT 令牌无效或已过期")
|
||||
|
||||
print()
|
||||
|
||||
async def test_api_key_authentication():
|
||||
"""测试 API 密钥认证"""
|
||||
print("🔑 测试 API 密钥认证")
|
||||
print("-" * 50)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 首先创建一个 API 密钥(需要管理员权限)
|
||||
# 注意:这里我们使用一个临时的认证方式
|
||||
|
||||
# 方法1:直接使用内存中的 API 密钥(仅用于演示)
|
||||
# 在实际应用中,API 密钥应该通过管理界面创建
|
||||
|
||||
# 模拟一个有效的 API 密钥
|
||||
api_key = "test_api_key_12345"
|
||||
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
# 测试访问日志端点
|
||||
async with session.get(f"{BASE_URL}/api/logs", headers=headers) as response:
|
||||
if response.status == 200:
|
||||
logs = await response.json()
|
||||
print("✅ API 密钥认证成功 - 日志访问")
|
||||
print(f" 获取到 {len(logs)} 条日志")
|
||||
else:
|
||||
print(f"❌ API 密钥认证失败 - 日志访问: {response.status}")
|
||||
if response.status == 401:
|
||||
print(" 原因: API 密钥无效或已撤销")
|
||||
|
||||
print()
|
||||
|
||||
async def test_public_endpoints():
|
||||
"""测试公开端点(无需认证)"""
|
||||
print("🌐 测试公开端点")
|
||||
print("-" * 50)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 健康检查端点(无需认证)
|
||||
async with session.get(f"{BASE_URL}/health") as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
print("✅ 健康检查端点访问成功")
|
||||
print(f" 状态: {data['status']}")
|
||||
print(f" Jenkins: {data['jenkins']['status']}")
|
||||
else:
|
||||
print(f"❌ 健康检查端点访问失败: {response.status}")
|
||||
|
||||
# Webhook 端点(无需认证)
|
||||
webhook_data = {"test": "webhook_data"}
|
||||
async with session.post(f"{BASE_URL}/webhook/gitea", json=webhook_data) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
print("✅ Webhook 端点访问成功")
|
||||
print(f" 响应: {data['message']}")
|
||||
else:
|
||||
print(f"❌ Webhook 端点访问失败: {response.status}")
|
||||
|
||||
print()
|
||||
|
||||
async def test_authentication_flow():
|
||||
"""测试完整的认证流程"""
|
||||
print("🔄 测试完整认证流程")
|
||||
print("-" * 50)
|
||||
|
||||
print("📋 认证流程说明:")
|
||||
print("1. 公开端点: /health, /webhook/gitea - 无需认证")
|
||||
print("2. 管理端点: /api/admin/* - 需要 JWT 或 API 密钥")
|
||||
print("3. 日志端点: /api/logs/* - 需要 JWT 或 API 密钥")
|
||||
print()
|
||||
|
||||
print("🔧 如何获取认证令牌:")
|
||||
print("1. JWT 令牌: 通过登录端点获取(需要实现登录功能)")
|
||||
print("2. API 密钥: 通过管理界面创建(需要管理员权限)")
|
||||
print()
|
||||
|
||||
print("⚠️ 当前演示限制:")
|
||||
print("- 使用模拟的认证令牌")
|
||||
print("- 实际应用中需要实现完整的登录和密钥管理")
|
||||
print("- 建议在生产环境中使用真实的认证系统")
|
||||
|
||||
print()
|
||||
|
||||
async def create_demo_api_key():
|
||||
"""创建演示用的 API 密钥"""
|
||||
print("🔧 创建演示 API 密钥")
|
||||
print("-" * 50)
|
||||
|
||||
# 注意:这是一个简化的演示
|
||||
# 在实际应用中,API 密钥应该通过安全的方式创建和存储
|
||||
|
||||
demo_api_key = "demo_api_key_" + str(int(datetime.now().timestamp()))
|
||||
|
||||
print(f"✅ 演示 API 密钥已创建: {demo_api_key}")
|
||||
print("📝 使用方法:")
|
||||
print(f" curl -H 'Authorization: Bearer {demo_api_key}' {BASE_URL}/api/logs")
|
||||
print()
|
||||
|
||||
return demo_api_key
|
||||
|
||||
async def main():
|
||||
"""主测试函数"""
|
||||
print("🚀 开始认证功能测试")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
try:
|
||||
# 等待服务启动
|
||||
await asyncio.sleep(2)
|
||||
|
||||
await test_public_endpoints()
|
||||
await test_jwt_authentication()
|
||||
await test_api_key_authentication()
|
||||
await test_authentication_flow()
|
||||
|
||||
# 创建演示 API 密钥
|
||||
demo_key = await create_demo_api_key()
|
||||
|
||||
print("=" * 60)
|
||||
print("🎉 认证功能测试完成!")
|
||||
print()
|
||||
print("📚 下一步建议:")
|
||||
print("1. 实现完整的登录系统")
|
||||
print("2. 添加用户管理功能")
|
||||
print("3. 实现 API 密钥的安全存储")
|
||||
print("4. 添加权限控制机制")
|
||||
print("5. 实现会话管理")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 测试过程中出现错误: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
225
apps/gitea-webhook-ambassador-python/test_enhanced.py
Normal file
225
apps/gitea-webhook-ambassador-python/test_enhanced.py
Normal file
@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
增强版 Gitea Webhook Ambassador 功能测试脚本
|
||||
演示所有新增的监测和管理功能
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
async def test_health_check():
|
||||
"""测试增强的健康检查"""
|
||||
print("🧪 测试增强的健康检查")
|
||||
print("-" * 50)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{BASE_URL}/health") as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
print("✅ 健康检查通过")
|
||||
print(f" 状态: {data['status']}")
|
||||
print(f" 服务: {data['service']}")
|
||||
print(f" Jenkins: {data['jenkins']['status']}")
|
||||
print(f" 工作池: {data['worker_pool']['active_workers']} 活跃工作线程")
|
||||
print(f" 队列大小: {data['worker_pool']['queue_size']}")
|
||||
print(f" 已处理: {data['worker_pool']['total_processed']}")
|
||||
print(f" 失败: {data['worker_pool']['total_failed']}")
|
||||
else:
|
||||
print(f"❌ 健康检查失败: {response.status}")
|
||||
|
||||
print()
|
||||
|
||||
async def test_webhook():
|
||||
"""测试 Webhook 功能"""
|
||||
print("🧪 测试 Webhook 功能")
|
||||
print("-" * 50)
|
||||
|
||||
webhook_data = {
|
||||
"ref": "refs/heads/dev",
|
||||
"before": "abc123",
|
||||
"after": "def456",
|
||||
"repository": {
|
||||
"full_name": "freeleaps/test-project",
|
||||
"clone_url": "https://gitea.freeleaps.com/freeleaps/test-project.git"
|
||||
},
|
||||
"pusher": {
|
||||
"login": "developer",
|
||||
"email": "dev@freeleaps.com"
|
||||
}
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
f"{BASE_URL}/webhook/gitea",
|
||||
json=webhook_data
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
print("✅ Webhook 处理成功")
|
||||
print(f" 响应: {data['message']}")
|
||||
print(f" 数据大小: {data['data']['body_size']} bytes")
|
||||
else:
|
||||
print(f"❌ Webhook 处理失败: {response.status}")
|
||||
|
||||
print()
|
||||
|
||||
async def test_api_key_management():
|
||||
"""测试 API 密钥管理"""
|
||||
print("🧪 测试 API 密钥管理")
|
||||
print("-" * 50)
|
||||
|
||||
# 创建 API 密钥
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 创建密钥
|
||||
create_data = {"name": "test-api-key"}
|
||||
async with session.post(
|
||||
f"{BASE_URL}/api/admin/api-keys",
|
||||
json=create_data,
|
||||
headers={"Authorization": "Bearer test-token"}
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
api_key = data['key']
|
||||
key_id = data['id']
|
||||
print(f"✅ API 密钥创建成功")
|
||||
print(f" ID: {key_id}")
|
||||
print(f" 名称: {data['name']}")
|
||||
print(f" 密钥: {api_key[:8]}...{api_key[-8:]}")
|
||||
|
||||
# 使用新创建的密钥测试日志端点
|
||||
print("\n 测试使用新密钥访问日志端点...")
|
||||
async with session.get(
|
||||
f"{BASE_URL}/api/logs",
|
||||
headers={"Authorization": f"Bearer {api_key}"}
|
||||
) as log_response:
|
||||
if log_response.status == 200:
|
||||
logs = await log_response.json()
|
||||
print(f" ✅ 日志访问成功,获取到 {len(logs)} 条日志")
|
||||
else:
|
||||
print(f" ❌ 日志访问失败: {log_response.status}")
|
||||
|
||||
# 删除密钥
|
||||
async with session.delete(
|
||||
f"{BASE_URL}/api/admin/api-keys/{key_id}",
|
||||
headers={"Authorization": f"Bearer {api_key}"}
|
||||
) as delete_response:
|
||||
if delete_response.status == 200:
|
||||
print(f" ✅ API 密钥删除成功")
|
||||
else:
|
||||
print(f" ❌ API 密钥删除失败: {delete_response.status}")
|
||||
else:
|
||||
print(f"❌ API 密钥创建失败: {response.status}")
|
||||
|
||||
print()
|
||||
|
||||
async def test_project_mapping():
|
||||
"""测试项目映射管理"""
|
||||
print("🧪 测试项目映射管理")
|
||||
print("-" * 50)
|
||||
|
||||
mapping_data = {
|
||||
"repository_name": "freeleaps/test-project",
|
||||
"default_job": "test-project-build",
|
||||
"branch_jobs": [
|
||||
{"branch": "dev", "job": "test-project-dev"},
|
||||
{"branch": "staging", "job": "test-project-staging"}
|
||||
],
|
||||
"branch_patterns": [
|
||||
{"pattern": "feature/*", "job": "test-project-feature"},
|
||||
{"pattern": "hotfix/*", "job": "test-project-hotfix"}
|
||||
]
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 创建项目映射
|
||||
async with session.post(
|
||||
f"{BASE_URL}/api/admin/projects",
|
||||
json=mapping_data,
|
||||
headers={"Authorization": "Bearer test-token"}
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
print("✅ 项目映射创建成功")
|
||||
print(f" ID: {data['id']}")
|
||||
print(f" 仓库: {data['repository_name']}")
|
||||
print(f" 默认任务: {data['default_job']}")
|
||||
print(f" 分支任务数: {len(data['branch_jobs'])}")
|
||||
print(f" 分支模式数: {len(data['branch_patterns'])}")
|
||||
else:
|
||||
print(f"❌ 项目映射创建失败: {response.status}")
|
||||
|
||||
print()
|
||||
|
||||
async def test_logs_and_stats():
|
||||
"""测试日志和统计功能"""
|
||||
print("🧪 测试日志和统计功能")
|
||||
print("-" * 50)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
# 获取日志统计
|
||||
async with session.get(
|
||||
f"{BASE_URL}/api/logs/stats",
|
||||
headers={"Authorization": "Bearer test-token"}
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
stats = await response.json()
|
||||
print("✅ 日志统计获取成功")
|
||||
print(f" 总日志数: {stats['total_logs']}")
|
||||
print(f" 成功日志: {stats['successful_logs']}")
|
||||
print(f" 失败日志: {stats['failed_logs']}")
|
||||
print(f" 最近24小时: {stats['recent_logs_24h']}")
|
||||
print(f" 仓库统计: {len(stats['repository_stats'])} 个仓库")
|
||||
else:
|
||||
print(f"❌ 日志统计获取失败: {response.status}")
|
||||
|
||||
print()
|
||||
|
||||
async def test_admin_stats():
|
||||
"""测试管理统计"""
|
||||
print("🧪 测试管理统计")
|
||||
print("-" * 50)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{BASE_URL}/api/admin/stats",
|
||||
headers={"Authorization": "Bearer test-token"}
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
stats = await response.json()
|
||||
print("✅ 管理统计获取成功")
|
||||
print(f" API 密钥总数: {stats['api_keys']['total']}")
|
||||
print(f" 活跃密钥: {stats['api_keys']['active']}")
|
||||
print(f" 最近使用: {stats['api_keys']['recently_used']}")
|
||||
print(f" 项目映射总数: {stats['project_mappings']['total']}")
|
||||
else:
|
||||
print(f"❌ 管理统计获取失败: {response.status}")
|
||||
|
||||
print()
|
||||
|
||||
async def main():
|
||||
"""主测试函数"""
|
||||
print("🚀 开始增强版 Gitea Webhook Ambassador 功能测试")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
try:
|
||||
await test_health_check()
|
||||
await test_webhook()
|
||||
await test_api_key_management()
|
||||
await test_project_mapping()
|
||||
await test_logs_and_stats()
|
||||
await test_admin_stats()
|
||||
|
||||
print("=" * 60)
|
||||
print("🎉 所有测试完成!")
|
||||
print("✅ Python 版本现在具备了与 Go 版本相同的监测和管理功能")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 测试过程中出现错误: {str(e)}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
226
apps/gitea-webhook-ambassador-python/test_enhanced_features.py
Normal file
226
apps/gitea-webhook-ambassador-python/test_enhanced_features.py
Normal file
@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Gitea Webhook Ambassador 增强版功能测试脚本
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
ADMIN_SECRET_KEY = "admin-secret-key-change-in-production"
|
||||
|
||||
def test_health_check():
|
||||
"""测试健康检查"""
|
||||
print("🔍 测试健康检查...")
|
||||
try:
|
||||
response = requests.get(f"{BASE_URL}/health")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✅ 健康检查成功: {data['status']}")
|
||||
print(f" 版本: {data['version']}")
|
||||
print(f" 运行时间: {data['uptime']}")
|
||||
print(f" 内存使用: {data['memory']}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 健康检查失败: {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 健康检查异常: {e}")
|
||||
return False
|
||||
|
||||
def test_login():
|
||||
"""测试登录功能"""
|
||||
print("\n🔐 测试登录功能...")
|
||||
try:
|
||||
# 测试登录 API
|
||||
login_data = {"secret_key": ADMIN_SECRET_KEY}
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/auth/login",
|
||||
json=login_data,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
token = data.get("token")
|
||||
print(f"✅ 登录成功,获得 JWT 令牌")
|
||||
return token
|
||||
else:
|
||||
print(f"❌ 登录失败: {response.status_code} - {response.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ 登录异常: {e}")
|
||||
return None
|
||||
|
||||
def test_api_key_management(token):
|
||||
"""测试 API 密钥管理"""
|
||||
print("\n🔑 测试 API 密钥管理...")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
# 创建 API 密钥
|
||||
key_data = {"description": "测试 API 密钥"}
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/keys",
|
||||
json=key_data,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
api_key = data["key"]
|
||||
key_id = data["id"]
|
||||
print(f"✅ 创建 API 密钥成功: {api_key[:20]}...")
|
||||
|
||||
# 列出 API 密钥
|
||||
response = requests.get(f"{BASE_URL}/api/keys", headers=headers)
|
||||
if response.status_code == 200:
|
||||
keys_data = response.json()
|
||||
print(f"✅ 列出 API 密钥成功,共 {len(keys_data['keys'])} 个")
|
||||
|
||||
# 删除 API 密钥
|
||||
response = requests.delete(f"{BASE_URL}/api/keys/{key_id}", headers=headers)
|
||||
if response.status_code == 200:
|
||||
print(f"✅ 删除 API 密钥成功")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 删除 API 密钥失败: {response.status_code}")
|
||||
return False
|
||||
else:
|
||||
print(f"❌ 列出 API 密钥失败: {response.status_code}")
|
||||
return False
|
||||
else:
|
||||
print(f"❌ 创建 API 密钥失败: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ API 密钥管理异常: {e}")
|
||||
return False
|
||||
|
||||
def test_project_management(token):
|
||||
"""测试项目管理"""
|
||||
print("\n📁 测试项目管理...")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
# 创建项目
|
||||
project_data = {
|
||||
"name": "测试项目",
|
||||
"jenkinsJob": "test-job",
|
||||
"giteaRepo": "test-owner/test-repo"
|
||||
}
|
||||
response = requests.post(
|
||||
f"{BASE_URL}/api/projects/",
|
||||
json=project_data,
|
||||
headers=headers
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
project_id = data["id"]
|
||||
print(f"✅ 创建项目成功: {data['name']}")
|
||||
|
||||
# 列出项目
|
||||
response = requests.get(f"{BASE_URL}/api/projects/", headers=headers)
|
||||
if response.status_code == 200:
|
||||
projects_data = response.json()
|
||||
print(f"✅ 列出项目成功,共 {len(projects_data['projects'])} 个")
|
||||
|
||||
# 删除项目
|
||||
response = requests.delete(f"{BASE_URL}/api/projects/{project_id}", headers=headers)
|
||||
if response.status_code == 200:
|
||||
print(f"✅ 删除项目成功")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 删除项目失败: {response.status_code}")
|
||||
return False
|
||||
else:
|
||||
print(f"❌ 列出项目失败: {response.status_code}")
|
||||
return False
|
||||
else:
|
||||
print(f"❌ 创建项目失败: {response.status_code} - {response.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 项目管理异常: {e}")
|
||||
return False
|
||||
|
||||
def test_stats(token):
|
||||
"""测试统计信息"""
|
||||
print("\n📊 测试统计信息...")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
response = requests.get(f"{BASE_URL}/api/stats", headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✅ 获取统计信息成功:")
|
||||
print(f" 总项目数: {data['total_projects']}")
|
||||
print(f" API 密钥数: {data['total_api_keys']}")
|
||||
print(f" 今日触发次数: {data['today_triggers']}")
|
||||
print(f" 成功触发次数: {data['successful_triggers']}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 获取统计信息失败: {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 统计信息异常: {e}")
|
||||
return False
|
||||
|
||||
def test_logs(token):
|
||||
"""测试日志功能"""
|
||||
print("\n📝 测试日志功能...")
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
try:
|
||||
response = requests.get(f"{BASE_URL}/api/logs", headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✅ 获取日志成功,共 {len(data['logs'])} 条记录")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 获取日志失败: {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 日志功能异常: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""主测试函数"""
|
||||
print("🚀 Gitea Webhook Ambassador 增强版功能测试")
|
||||
print("=" * 50)
|
||||
|
||||
# 测试健康检查
|
||||
if not test_health_check():
|
||||
print("❌ 健康检查失败,服务可能未启动")
|
||||
return
|
||||
|
||||
# 测试登录
|
||||
token = test_login()
|
||||
if not token:
|
||||
print("❌ 登录失败,无法继续测试")
|
||||
return
|
||||
|
||||
# 测试各项功能
|
||||
test_api_key_management(token)
|
||||
test_project_management(token)
|
||||
test_stats(token)
|
||||
test_logs(token)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("🎉 增强版功能测试完成!")
|
||||
print("\n📋 已实现的功能:")
|
||||
print(" ✅ Web 登录界面")
|
||||
print(" ✅ 数据库存储 API 密钥")
|
||||
print(" ✅ 延长 JWT 有效期 (7天)")
|
||||
print(" ✅ 前端仪表板")
|
||||
print(" ✅ 项目管理")
|
||||
print(" ✅ API 密钥管理")
|
||||
print(" ✅ 日志查看")
|
||||
print(" ✅ 健康状态监控")
|
||||
print("\n🌐 访问地址:")
|
||||
print(f" 登录页面: {BASE_URL}/login")
|
||||
print(f" 仪表板: {BASE_URL}/dashboard")
|
||||
print(f" 管理员密钥: {ADMIN_SECRET_KEY}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
307
apps/gitea-webhook-ambassador-python/test_webhook.py
Executable file
307
apps/gitea-webhook-ambassador-python/test_webhook.py
Executable file
@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Webhook 功能测试脚本
|
||||
用于验证 Gitea Webhook Ambassador 的各项功能
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import httpx
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
# 测试配置
|
||||
BASE_URL = "http://localhost:8000"
|
||||
WEBHOOK_SECRET = "your-secret-key-here-make-it-long-and-random"
|
||||
|
||||
# 测试数据
|
||||
TEST_WEBHOOK_DATA = {
|
||||
"ref": "refs/heads/dev",
|
||||
"before": "abc1234567890abcdef1234567890abcdef123456",
|
||||
"after": "def1234567890abcdef1234567890abcdef123456",
|
||||
"compare_url": "https://gitea.freeleaps.com/freeleaps/test-project/compare/abc123...def123",
|
||||
"commits": [
|
||||
{
|
||||
"id": "def1234567890abcdef1234567890abcdef123456",
|
||||
"message": "feat: add new feature",
|
||||
"url": "https://gitea.freeleaps.com/freeleaps/test-project/commit/def1234567890abcdef1234567890abcdef123456",
|
||||
"author": {
|
||||
"id": 1,
|
||||
"login": "developer",
|
||||
"full_name": "Test Developer",
|
||||
"email": "dev@freeleaps.com"
|
||||
}
|
||||
}
|
||||
],
|
||||
"repository": {
|
||||
"id": 1,
|
||||
"name": "test-project",
|
||||
"owner": {
|
||||
"id": 1,
|
||||
"login": "freeleaps",
|
||||
"full_name": "Freeleaps Team",
|
||||
"email": "team@freeleaps.com"
|
||||
},
|
||||
"full_name": "freeleaps/test-project",
|
||||
"private": False,
|
||||
"clone_url": "https://gitea.freeleaps.com/freeleaps/test-project.git",
|
||||
"ssh_url": "git@gitea.freeleaps.com:freeleaps/test-project.git",
|
||||
"html_url": "https://gitea.freeleaps.com/freeleaps/test-project",
|
||||
"default_branch": "main"
|
||||
},
|
||||
"pusher": {
|
||||
"id": 1,
|
||||
"login": "developer",
|
||||
"full_name": "Test Developer",
|
||||
"email": "dev@freeleaps.com"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def test_health_check():
|
||||
"""测试健康检查"""
|
||||
print("🔍 测试健康检查...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(f"{BASE_URL}/health")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✅ 健康检查通过: {data['status']}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 健康检查失败: {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 健康检查异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_queue_status():
|
||||
"""测试队列状态"""
|
||||
print("🔍 测试队列状态...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(f"{BASE_URL}/health/queue")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
print(f"✅ 队列状态: {data['queue_stats']}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 队列状态检查失败: {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 队列状态检查异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_webhook_endpoint():
|
||||
"""测试 Webhook 端点"""
|
||||
print("🔍 测试 Webhook 端点...")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Gitea-Signature": WEBHOOK_SECRET
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
f"{BASE_URL}/webhook/gitea",
|
||||
headers=headers,
|
||||
json=TEST_WEBHOOK_DATA
|
||||
)
|
||||
|
||||
print(f"📊 响应状态: {response.status_code}")
|
||||
print(f"📊 响应内容: {response.text}")
|
||||
|
||||
if response.status_code in [200, 202]:
|
||||
print("✅ Webhook 端点测试通过")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Webhook 端点测试失败: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Webhook 端点测试异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_metrics_endpoint():
|
||||
"""测试监控指标端点"""
|
||||
print("🔍 测试监控指标端点...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(f"{BASE_URL}/metrics")
|
||||
if response.status_code == 200:
|
||||
print("✅ 监控指标端点测试通过")
|
||||
# 打印一些关键指标
|
||||
content = response.text
|
||||
for line in content.split('\n'):
|
||||
if 'webhook_requests_total' in line or 'queue_size' in line:
|
||||
print(f"📊 {line}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ 监控指标端点测试失败: {response.status_code}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 监控指标端点测试异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_deduplication():
|
||||
"""测试防抖功能"""
|
||||
print("🔍 测试防抖功能...")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Gitea-Signature": WEBHOOK_SECRET
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
# 第一次请求
|
||||
print("📤 发送第一次请求...")
|
||||
response1 = await client.post(
|
||||
f"{BASE_URL}/webhook/gitea",
|
||||
headers=headers,
|
||||
json=TEST_WEBHOOK_DATA
|
||||
)
|
||||
print(f"📊 第一次响应: {response1.status_code}")
|
||||
|
||||
# 等待一秒
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 第二次请求(相同数据,应该被防抖)
|
||||
print("📤 发送第二次请求(相同数据)...")
|
||||
response2 = await client.post(
|
||||
f"{BASE_URL}/webhook/gitea",
|
||||
headers=headers,
|
||||
json=TEST_WEBHOOK_DATA
|
||||
)
|
||||
print(f"📊 第二次响应: {response2.status_code}")
|
||||
|
||||
# 修改提交哈希,发送第三次请求
|
||||
modified_data = TEST_WEBHOOK_DATA.copy()
|
||||
modified_data["after"] = "ghi1234567890abcdef1234567890abcdef123456"
|
||||
|
||||
print("📤 发送第三次请求(不同提交哈希)...")
|
||||
response3 = await client.post(
|
||||
f"{BASE_URL}/webhook/gitea",
|
||||
headers=headers,
|
||||
json=modified_data
|
||||
)
|
||||
print(f"📊 第三次响应: {response3.status_code}")
|
||||
|
||||
print("✅ 防抖功能测试完成")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 防抖功能测试异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def test_invalid_webhook():
|
||||
"""测试无效的 Webhook 请求"""
|
||||
print("🔍 测试无效的 Webhook 请求...")
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
# 测试缺少签名
|
||||
print("📤 测试缺少签名...")
|
||||
response1 = await client.post(
|
||||
f"{BASE_URL}/webhook/gitea",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json=TEST_WEBHOOK_DATA
|
||||
)
|
||||
print(f"📊 缺少签名响应: {response1.status_code}")
|
||||
|
||||
# 测试错误的签名
|
||||
print("📤 测试错误的签名...")
|
||||
response2 = await client.post(
|
||||
f"{BASE_URL}/webhook/gitea",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Gitea-Signature": "wrong-secret"
|
||||
},
|
||||
json=TEST_WEBHOOK_DATA
|
||||
)
|
||||
print(f"📊 错误签名响应: {response2.status_code}")
|
||||
|
||||
# 测试无效的 JSON
|
||||
print("📤 测试无效的 JSON...")
|
||||
response3 = await client.post(
|
||||
f"{BASE_URL}/webhook/gitea",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Gitea-Signature": WEBHOOK_SECRET
|
||||
},
|
||||
content="invalid json"
|
||||
)
|
||||
print(f"📊 无效 JSON 响应: {response3.status_code}")
|
||||
|
||||
print("✅ 无效请求测试完成")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 无效请求测试异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def main():
|
||||
"""主测试函数"""
|
||||
print("🚀 开始 Gitea Webhook Ambassador 功能测试")
|
||||
print("=" * 50)
|
||||
|
||||
tests = [
|
||||
("健康检查", test_health_check),
|
||||
("队列状态", test_queue_status),
|
||||
("Webhook 端点", test_webhook_endpoint),
|
||||
("监控指标", test_metrics_endpoint),
|
||||
("防抖功能", test_deduplication),
|
||||
("无效请求", test_invalid_webhook),
|
||||
]
|
||||
|
||||
results = []
|
||||
|
||||
for test_name, test_func in tests:
|
||||
print(f"\n🧪 {test_name}")
|
||||
print("-" * 30)
|
||||
|
||||
try:
|
||||
result = await test_func()
|
||||
results.append((test_name, result))
|
||||
except Exception as e:
|
||||
print(f"❌ {test_name} 测试异常: {e}")
|
||||
results.append((test_name, False))
|
||||
|
||||
# 等待一下再进行下一个测试
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# 输出测试结果
|
||||
print("\n" + "=" * 50)
|
||||
print("📊 测试结果汇总")
|
||||
print("=" * 50)
|
||||
|
||||
passed = 0
|
||||
total = len(results)
|
||||
|
||||
for test_name, result in results:
|
||||
status = "✅ 通过" if result else "❌ 失败"
|
||||
print(f"{test_name}: {status}")
|
||||
if result:
|
||||
passed += 1
|
||||
|
||||
print(f"\n📈 总体结果: {passed}/{total} 测试通过")
|
||||
|
||||
if passed == total:
|
||||
print("🎉 所有测试通过!服务运行正常。")
|
||||
else:
|
||||
print("⚠️ 部分测试失败,请检查服务配置和日志。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 运行测试
|
||||
asyncio.run(main())
|
||||
Loading…
Reference in New Issue
Block a user