Files
git-hook/main.go
T
2026-08-14 13:32:37 +08:00

258 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// Config 对应 config.json 结构
type Config struct {
SecretToken string `json:"secret_token"`
Projects map[string]map[string]string `json:"projects"`
DeviceKeys []string `json:"device_keys"`
}
// GitLabPayload 简化版的 GitLab Push Webhook 数据结构
type GitLabPayload struct {
Ref string `json:"ref"`
After string `json:"after"`
Project struct {
PathWithNamespace string `json:"path_with_namespace"`
} `json:"project"`
}
// BarkPushPayload 定义 Bark 推送的请求 JSON 结构(单 device_key
type BarkPushPayload struct {
Title string `json:"title"`
Body string `json:"body"`
Group string `json:"group,omitempty"`
DeviceKey string `json:"device_key"`
IsArchive string `json:"isArchive,omitempty"`
TTL int `json:"ttl,omitempty"`
}
// SendNotification 依次对每个 device_key 发送 Bark 通知,遇错即返回
func SendNotification(pushURL string, deviceKeys []string, title, body string) error {
for _, key := range deviceKeys {
if err := sendOne(pushURL, key, title, body); err != nil {
return err
}
}
return nil
}
// sendOne 向单个 device_key 发送推送
func sendOne(pushURL, deviceKey, title, body string) error {
payload := BarkPushPayload{
Title: title,
Body: body,
Group: "Model Development Platform",
DeviceKey: deviceKey,
IsArchive: "1",
TTL: 3600,
}
jsonBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("JSON 序列化失败: %w", err)
}
req, err := http.NewRequest("POST", pushURL, bytes.NewBuffer(jsonBytes))
if err != nil {
return fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("发送推送请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
bodyText := string(bytes.TrimSpace(bodyBytes))
if bodyText != "" {
return fmt.Errorf("推送服务器返回异常状态码: %d, 响应: %s", resp.StatusCode, bodyText)
}
return fmt.Errorf("推送服务器返回异常状态码: %d", resp.StatusCode)
}
return nil
}
var (
globalConfig Config
// lastExecutionMap 用于记录某个仓库+分支的上一次触发时间 key: "repoName:ref", value: time.Time
lastExecutionMap sync.Map
// 防抖冷却时间限制:5 分钟
debounceDuration = 5 * time.Minute
// bark 推送消息
pushURL = "https://bark.maimaicuizhiji.top/push"
)
func loadConfig(path string) error {
file, err := os.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(file, &globalConfig)
}
// 异步执行 Shell 脚本
func runScript(scriptPath, repoName, ref, commitID string) {
log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s)\n", scriptPath, repoName, ref)
cmd := exec.Command("bash", scriptPath)
// 传入环境变量,脚本内可读取
cmd.Env = append(os.Environ(),
fmt.Sprintf("GIT_REPO_NAME=%s", repoName),
fmt.Sprintf("GIT_REF=%s", ref),
fmt.Sprintf("GIT_COMMIT_ID=%s", commitID),
)
// 捕获输出
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("[ERROR] 脚本执行失败 [%s]: %v\n输出:\n%s\n", scriptPath, err, string(output))
return
}
log.Printf("[SUCCESS] 脚本执行成功 [%s]\n输出:\n%s\n", scriptPath, string(output))
title := fmt.Sprintf("%s更新成功", repoName)
body := fmt.Sprintf("分支: %s\nCommit: %s\n服务器已接收到 Push,开始更新容器...", ref, commitID[:7])
if err := SendNotification(pushURL, globalConfig.DeviceKeys, title, body); err != nil {
log.Printf("[WARNING] 推送通知失败: %v\n", err)
} else {
log.Println("[INFO] 推送通知发送成功")
}
}
func handleWebhook(c *gin.Context) {
// 1. 校验 GitLab Secret Token Header
clientToken := c.GetHeader("X-Gitlab-Token")
if globalConfig.SecretToken != "" && clientToken != globalConfig.SecretToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid Secret Token"})
return
}
// 2. 校验 Event 类型
eventType := c.GetHeader("X-Gitlab-Event")
if eventType != "Push Hook" {
c.JSON(http.StatusOK, gin.H{"status": "ignored", "reason": "Only Push Hook is handled"})
return
}
// 3. 解析 Body JSON
var payload GitLabPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON Payload"})
return
}
repoName := payload.Project.PathWithNamespace
ref := payload.Ref
commitID := payload.After
if repoName == "" || ref == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing repo or ref in payload"})
return
}
// 4. 匹配脚本路径
repoConfig, exists := globalConfig.Projects[repoName]
if !exists {
c.JSON(http.StatusOK, gin.H{"status": "ignored", "message": "No script config for repository " + repoName})
return
}
scriptPath, exists := repoConfig[ref]
if !exists {
c.JSON(http.StatusOK, gin.H{"status": "ignored", "message": fmt.Sprintf("No script config for %s on %s", repoName, ref)})
return
}
// 5. 校验脚本文件是否存在
if _, err := os.Stat(scriptPath); os.IsNotExist(err) {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Script file not found: " + scriptPath})
return
}
// 6. 核心逻辑:5 分钟防抖检查
lockKey := fmt.Sprintf("%s:%s", repoName, ref)
now := time.Now()
if lastTimeVal, loaded := lastExecutionMap.Load(lockKey); loaded {
lastTime := lastTimeVal.(time.Time)
elapsed := now.Sub(lastTime)
if elapsed < debounceDuration {
remainingSeconds := int((debounceDuration - elapsed).Seconds())
msg := fmt.Sprintf("触发太频繁,防抖拦截中(5分钟限制),剩余等待时间: %d 秒", remainingSeconds)
log.Printf("[DEBOUNCE] 跳过执行 [%s] -> %s\n", lockKey, msg)
c.JSON(http.StatusOK, gin.H{
"status": "debounced",
"message": msg,
"remaining_seconds": remainingSeconds,
})
return
}
}
// 更新上一次触发时间
lastExecutionMap.Store(lockKey, now)
// 7. 开启 goroutine 异步执行脚本
go runScript(scriptPath, repoName, ref, commitID)
// 8. 立即回应 200 给 GitLab
c.JSON(http.StatusOK, gin.H{
"status": "triggered",
"repository": repoName,
"ref": ref,
"script": scriptPath,
})
}
func main() {
portFlag := flag.Int("port", 8000, "服务监听端口")
flag.Parse()
if err := loadConfig("config.json"); err != nil {
log.Fatalf("加载配置文件失败: %v", err)
}
//gin.SetMode(gin.ReleaseMode)
r := gin.New()
r.Use(gin.Recovery())
r.Use(func(c *gin.Context) {
t := time.Now()
c.Next()
log.Printf("[%s] %s %d %v", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(t))
})
r.POST("/webhook", handleWebhook)
listenAddr := fmt.Sprintf(":%d", *portFlag)
log.Printf("GitLab Webhook 服务已启动 (带 5 分钟防抖),监听端口 %d ...\n", *portFlag)
if err := r.Run(listenAddr); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
}