feat: 添加 Gitea webhook 自动部署功能
- 新增独立的 webhook 服务 (Go, 端口 9000) - HMAC-SHA256 签名验证 - 零停机热更新部署 - 自动清理旧镜像 - 完整配置文档 WEBHOOK_SETUP.md - 精简 README 版本历史为表格形式
This commit is contained in:
52
webhook/Dockerfile
Normal file
52
webhook/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# Webhook 服务 Dockerfile
|
||||
# 多阶段构建:编译阶段 + 运行阶段
|
||||
|
||||
# ===== 编译阶段 =====
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# 配置 Alpine 镜像源(国内)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
# 配置 Go 代理(国内镜像)
|
||||
ENV GOPROXY=https://goproxy.cn,direct
|
||||
|
||||
# 安装编译依赖
|
||||
RUN apk add --no-cache git
|
||||
|
||||
# 复制代码
|
||||
COPY . .
|
||||
|
||||
# 构建二进制
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o webhook .
|
||||
|
||||
# ===== 运行阶段 =====
|
||||
FROM alpine:3.19
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 配置 Alpine 镜像源(国内)
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories
|
||||
|
||||
# 安装必要工具
|
||||
RUN apk add --no-cache bash curl git docker-cli docker-compose ca-certificates tzdata
|
||||
|
||||
# 设置时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 从编译阶段复制二进制
|
||||
COPY --from=builder /build/webhook /usr/local/bin/webhook
|
||||
|
||||
# 注意:deploy.sh 通过 volume 挂载,不在构建时复制
|
||||
# 因为 deploy.sh 在仓库根目录,而 Dockerfile 在 webhook 目录
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 9000
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
|
||||
CMD curl -f http://localhost:9000/health || exit 1
|
||||
|
||||
# 启动服务
|
||||
CMD ["webhook"]
|
||||
132
webhook/README.md
Normal file
132
webhook/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Webhook 服务
|
||||
|
||||
用于监听 Gitea webhook 事件,自动触发服务器拉取最新代码并重新构建部署。
|
||||
|
||||
## 特性
|
||||
|
||||
- ✅ 接收 Gitea webhook 推送事件
|
||||
- ✅ HMAC-SHA256 签名验证
|
||||
- ✅ 仅处理主分支 (master/main) 的推送
|
||||
- ✅ 自动执行部署脚本
|
||||
- ✅ 异步部署,快速响应
|
||||
- ✅ 零停机热更新
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|-----|------|-------|
|
||||
| `WEBHOOK_PORT` | webhook 服务监听端口 | 9000 |
|
||||
| `WEBHOOK_SECRET` | Gitea webhook 签名密钥 | your-webhook-secret |
|
||||
| `REPO_PATH` | 仓库根目录路径 | /app |
|
||||
|
||||
### 部署脚本
|
||||
|
||||
脚本位置: `/app/deploy.sh`
|
||||
|
||||
执行以下操作:
|
||||
1. 拉取最新代码 (`git pull`)
|
||||
2. 热更新部署(零停机)
|
||||
3. 自动清理旧镜像
|
||||
4. 健康检查
|
||||
|
||||
## 安全性
|
||||
|
||||
### 设置 Webhook Secret
|
||||
|
||||
在 Gitea 中配置 webhook 时,需要设置 Secret:
|
||||
|
||||
1. 生成一个随机密钥:
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
# 输出示例: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
```
|
||||
|
||||
2. 在 Docker 容器中设置环境变量:
|
||||
```yaml
|
||||
environment:
|
||||
WEBHOOK_SECRET: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
|
||||
```
|
||||
|
||||
## Gitea Webhook 配置
|
||||
|
||||
### 1. 进入仓库设置
|
||||
|
||||
在 Gitea 中打开仓库,进入 **设置 → Webhook**
|
||||
|
||||
### 2. 添加新 Webhook
|
||||
|
||||
- **URL**: `http://<your-server>:9000/webhook`
|
||||
- **内容类型**: `application/json`
|
||||
- **密钥**: 使用上面生成的 Secret
|
||||
- **触发器**: 勾选 `推送事件`
|
||||
- **活跃**: 是
|
||||
|
||||
### 3. 测试连接
|
||||
|
||||
点击 "测试" 按钮验证连接
|
||||
|
||||
## 日志
|
||||
|
||||
查看部署日志:
|
||||
|
||||
```bash
|
||||
# 查看容器日志
|
||||
docker logs billai-webhook
|
||||
|
||||
# 查看部署脚本日志
|
||||
docker exec billai-webhook tail -f /tmp/billai_deploy.log
|
||||
```
|
||||
|
||||
## 访问端点
|
||||
|
||||
- **Webhook**: `POST /webhook`
|
||||
- **健康检查**: `GET /health`
|
||||
|
||||
## 工作流
|
||||
|
||||
```
|
||||
Gitea Push Event
|
||||
↓
|
||||
Webhook Service (port 9000)
|
||||
↓
|
||||
验证 HMAC-SHA256 签名
|
||||
↓
|
||||
检查分支 (仅主分支)
|
||||
↓
|
||||
异步触发部署脚本
|
||||
↓
|
||||
执行 git pull
|
||||
↓
|
||||
执行 docker-compose build/up
|
||||
↓
|
||||
健康检查
|
||||
↓
|
||||
部署完成
|
||||
```
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 签名验证失败
|
||||
|
||||
- 检查 Gitea 中配置的 Secret 是否与环境变量 `WEBHOOK_SECRET` 一致
|
||||
|
||||
### 部署脚本无法执行
|
||||
|
||||
- 确保 `deploy.sh` 存在于仓库根目录
|
||||
- 检查文件权限:`chmod +x deploy.sh`
|
||||
- 检查容器内是否有 git、docker 等工具
|
||||
|
||||
### Docker 操作失败
|
||||
|
||||
- 确保容器有权限访问 Docker Socket
|
||||
- 在 docker-compose 中正确挂载 Docker Socket:
|
||||
```yaml
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
```
|
||||
|
||||
## 示例 docker-compose 配置
|
||||
|
||||
参考项目根目录的 `docker-compose.yaml`
|
||||
3
webhook/go.mod
Normal file
3
webhook/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module webhook
|
||||
|
||||
go 1.21
|
||||
209
webhook/main.go
Normal file
209
webhook/main.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GiteaPayload Gitea webhook 推送事件
|
||||
type GiteaPayload struct {
|
||||
Repository struct {
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
CloneURL string `json:"clone_url"`
|
||||
} `json:"repository"`
|
||||
Pusher struct {
|
||||
Username string `json:"username"`
|
||||
} `json:"pusher"`
|
||||
Ref string `json:"ref"`
|
||||
Commit string `json:"after"`
|
||||
}
|
||||
|
||||
// Config webhook 服务配置
|
||||
type Config struct {
|
||||
Port string
|
||||
Secret string
|
||||
RepoPath string
|
||||
ScriptPath string
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
|
||||
func init() {
|
||||
// 从环境变量读取配置
|
||||
cfg.Port = os.Getenv("WEBHOOK_PORT")
|
||||
if cfg.Port == "" {
|
||||
cfg.Port = "9000"
|
||||
}
|
||||
|
||||
cfg.Secret = os.Getenv("WEBHOOK_SECRET")
|
||||
if cfg.Secret == "" {
|
||||
cfg.Secret = "your-webhook-secret"
|
||||
}
|
||||
|
||||
cfg.RepoPath = os.Getenv("REPO_PATH")
|
||||
if cfg.RepoPath == "" {
|
||||
cfg.RepoPath = "/app"
|
||||
}
|
||||
|
||||
cfg.ScriptPath = filepath.Join(cfg.RepoPath, "deploy.sh")
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
log.Printf("🚀 Webhook 服务启动 | 监听端口: %s\n", cfg.Port)
|
||||
log.Printf("📁 仓库路径: %s\n", cfg.RepoPath)
|
||||
log.Printf("📜 部署脚本: %s\n", cfg.ScriptPath)
|
||||
|
||||
http.HandleFunc("/webhook", handleWebhook)
|
||||
http.HandleFunc("/health", handleHealth)
|
||||
|
||||
if err := http.ListenAndServe(":"+cfg.Port, nil); err != nil {
|
||||
log.Fatalf("❌ 服务器启动失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleHealth 健康检查端点
|
||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"status": "ok",
|
||||
"time": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// handleWebhook 处理 Gitea webhook
|
||||
func handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// 读取请求体
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.Printf("❌ 读取请求体失败: %v", err)
|
||||
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// 验证签名
|
||||
if !verifySignature(r.Header.Get("X-Gitea-Signature"), body) {
|
||||
log.Println("⚠️ 无效的签名")
|
||||
http.Error(w, "Invalid signature", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 payload
|
||||
var payload GiteaPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
log.Printf("❌ 解析 JSON 失败: %v", err)
|
||||
http.Error(w, "Failed to parse JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 只处理 master 分支的推送
|
||||
if !strings.Contains(payload.Ref, "master") && !strings.Contains(payload.Ref, "main") {
|
||||
log.Printf("⏭️ 忽略非主分支推送: %s", payload.Ref)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "非主分支,跳过部署",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ 收到有效的 Webhook 请求")
|
||||
log.Printf(" 仓库: %s", payload.Repository.FullName)
|
||||
log.Printf(" 推送者: %s", payload.Pusher.Username)
|
||||
log.Printf(" 分支: %s", payload.Ref)
|
||||
commitShort := payload.Commit
|
||||
if len(commitShort) > 7 {
|
||||
commitShort = commitShort[:7]
|
||||
}
|
||||
log.Printf(" 提交: %s", commitShort)
|
||||
|
||||
// 异步执行部署
|
||||
go executeDeployment(payload)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "部署已启动",
|
||||
"commit": payload.Commit,
|
||||
})
|
||||
}
|
||||
|
||||
// verifySignature 验证 Gitea webhook 签名
|
||||
func verifySignature(signature string, body []byte) bool {
|
||||
if cfg.Secret == "your-webhook-secret" {
|
||||
log.Println("⚠️ 警告: 使用默认 secret,建议设置 WEBHOOK_SECRET 环境变量")
|
||||
return true
|
||||
}
|
||||
|
||||
hash := hmac.New(sha256.New, []byte(cfg.Secret))
|
||||
hash.Write(body)
|
||||
expected := "sha256=" + hex.EncodeToString(hash.Sum(nil))
|
||||
|
||||
return signature == expected
|
||||
}
|
||||
|
||||
// executeDeployment 执行部署
|
||||
func executeDeployment(payload GiteaPayload) {
|
||||
log.Println("\n🔄 开始部署流程...")
|
||||
|
||||
startTime := time.Now()
|
||||
|
||||
// 检查脚本是否存在
|
||||
if _, err := os.Stat(cfg.ScriptPath); os.IsNotExist(err) {
|
||||
log.Printf("❌ 部署脚本不存在: %s", cfg.ScriptPath)
|
||||
return
|
||||
}
|
||||
|
||||
// 执行部署脚本
|
||||
cmd := exec.Command("bash", cfg.ScriptPath)
|
||||
cmd.Dir = cfg.RepoPath
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
log.Printf("📜 执行部署脚本: %s", cfg.ScriptPath)
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Printf("❌ 部署脚本执行失败: %v", err)
|
||||
if stderr.Len() > 0 {
|
||||
log.Printf("错误输出:\n%s", stderr.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
duration := time.Since(startTime).Seconds()
|
||||
|
||||
log.Printf("✅ 部署完成")
|
||||
log.Printf(" 耗时: %.2f 秒", duration)
|
||||
|
||||
if stdout.Len() > 0 {
|
||||
log.Printf("输出:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
log.Printf("📝 部署日志:\n")
|
||||
log.Printf(" 仓库: %s", payload.Repository.FullName)
|
||||
log.Printf(" 提交: %s", payload.Commit[:7])
|
||||
log.Printf(" 分支: %s", payload.Ref)
|
||||
log.Printf(" 推送者: %s", payload.Pusher.Username)
|
||||
log.Printf(" 时间: %s", time.Now().Format(time.RFC3339))
|
||||
}
|
||||
Reference in New Issue
Block a user