init
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
module git-webhook
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/gin-gonic/gin v1.12.0
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/net v0.51.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
@@ -0,0 +1,257 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user