Files
2026-09-11 18:16:23 +08:00

247 lines
6.9 KiB
Go
Raw Permalink 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 (
"bufio"
"context"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"strings"
"sync"
"time"
)
// scriptTimeout 脚本执行超时上限(6 小时)
const scriptTimeout = 6 * time.Hour
// buildTask 单次构建任务的快照,handler 入队时构造。
type buildTask struct {
scriptPath string
repoName string
ref string
commitID string
}
// debounceEntry 单 key 的冷却去重状态。
type debounceEntry struct {
mu sync.Mutex
lastExec time.Time
}
var (
// buildCh 单槽队列;handler enqueue 始终非阻塞,worker 唯一消费者
buildCh = make(chan buildTask, 1)
// lastExecMap 冷却去重状态,key: "repoName:ref", value: *debounceEntry
lastExecMap sync.Map
// 防抖冷却时间限制:3 分钟
debounceDuration = 3 * time.Minute
)
// enqueueBuildTask 入队;队列已满时丢老取新(drop-oldest)
func enqueueBuildTask(t buildTask) {
select {
case buildCh <- t:
// 入队成功后再登记到 dashboard,保证 channel 与列表一致
if dashboard != nil {
dashboard.AddPending(t)
}
return
default:
// 队列已满,丢弃旧 task
select {
case <-buildCh:
if dashboard != nil {
// 同步移除最旧一条,避免 pending 列表与 channel 错位
dashboard.RemovePendingAndPeek(t)
}
default:
}
buildCh <- t // 阻塞不会发生,因为我们是唯一发送方
if dashboard != nil {
dashboard.AddPending(t)
}
}
}
// startBuildWorker 启动单 worker goroutine 持续消费 buildCh
func startBuildWorker() {
go consumeBuild()
}
// consumeBuild worker 主循环;取出 task,按冷却规则执行或跳过
func consumeBuild() {
for task := range buildCh {
if dashboard != nil {
dashboard.RemovePendingAndPeek(task)
}
lockKey := fmt.Sprintf("%s:%s", task.repoName, task.ref)
entryVal, _ := lastExecMap.LoadOrStore(lockKey, &debounceEntry{})
entry := entryVal.(*debounceEntry)
entry.mu.Lock()
now := time.Now()
if !entry.lastExec.IsZero() && now.Sub(entry.lastExec) < debounceDuration {
remainingSeconds := int((debounceDuration - now.Sub(entry.lastExec)).Seconds())
if remainingSeconds < 0 {
remainingSeconds = 0
}
entry.mu.Unlock()
log.Printf("[SKIP] 3 分钟冷却中,跳过构建 [%s] commit %s 剩余 %d 秒\n",
lockKey, shortCommit(task.commitID), remainingSeconds)
// 冷却跳过也写入历史,方便追踪
if dashboard != nil {
dashboard.StartExecution(task)
dashboard.FinishExecution(task, statusSkipped,
fmt.Sprintf("冷却中,剩余 %d 秒", remainingSeconds),
time.Duration(0))
}
continue
}
entry.lastExec = now
entry.mu.Unlock()
log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s, commit: %s)\n",
task.scriptPath, task.repoName, task.ref, shortCommit(task.commitID))
go runScript(task.scriptPath, task.repoName, task.ref, task.commitID)
}
}
// shortCommit 安全地取 commit 短 ID,避免空值/短值切片越界 panic
func shortCommit(commitID string) string {
if len(commitID) > 7 {
return commitID[:7]
}
if commitID == "" {
return "unknown"
}
return commitID
}
// streamLog 逐行读取并实时打印脚本输出,直到 r 关闭
// onLine 在每行非空时回调一次(携带换行符)
func streamLog(r io.Reader, tag string, onLine func(line string)) {
br := bufio.NewReader(r)
for {
line, err := br.ReadString('\n')
if line != "" {
log.Printf("[SCRIPT %s] %s", tag, strings.TrimRight(line, "\r\n"))
if onLine != nil {
onLine(line)
}
}
if err != nil {
return
}
}
}
// notifyResult 发送执行结果通知
func notifyResult(ok bool, repoName, ref, commitID string, cost time.Duration, reason string) {
state := "成功"
if !ok {
state = "失败"
}
title := fmt.Sprintf("%s更新%s", repoName, state)
body := fmt.Sprintf("分支: %s\nCommit: %s\n耗时: %s\n原因: %s\n服务器已接收到 Push,更新%s",
ref, shortCommit(commitID), cost.Round(time.Second), reason, state)
if err := SendNotification(pushURL, globalConfig.DeviceKeys, title, body); err != nil {
log.Printf("[WARNING] 推送通知失败: %v", err)
return
}
log.Println("[INFO] 推送通知发送成功")
}
// runScript 异步执行 Shell 脚本,输出实时流式打印,超时上限 scriptTimeout
func runScript(scriptPath, repoName, ref, commitID string) {
task := buildTask{
scriptPath: scriptPath,
repoName: repoName,
ref: ref,
commitID: commitID,
}
start := time.Now()
tag := fmt.Sprintf("%s:%s", repoName, ref)
log.Printf("[INFO] 开始执行脚本: %s (仓库: %s, 分支: %s) 超时上限 %s",
scriptPath, repoName, ref, scriptTimeout)
if dashboard != nil {
dashboard.StartExecution(task)
}
ctx, cancel := context.WithTimeout(context.Background(), scriptTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", scriptPath)
// 超时/取消后最多再给 10 秒让进程优雅退出;超时则强制 SIGKILL
cmd.WaitDelay = 10 * time.Second
// 传入环境变量,脚本内可读取
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),
)
// stdout / stderr 合并到同一个管道,保持原有交错顺序
pr, pw := io.Pipe()
cmd.Stdout = pw
cmd.Stderr = pw
if err := cmd.Start(); err != nil {
pw.Close()
pr.Close()
log.Printf("[ERROR] 脚本启动失败 [%s]: %v", scriptPath, err)
notifyResult(false, repoName, ref, commitID, time.Since(start), fmt.Sprintf("启动失败: %v", err))
if dashboard != nil {
dashboard.FinishExecution(task, statusFailed,
fmt.Sprintf("启动失败: %v", err), time.Since(start))
}
return
}
var wg sync.WaitGroup
wg.Go(func() {
streamLog(pr, tag, func(line string) {
if dashboard != nil {
dashboard.AppendLog(line)
}
})
})
err := cmd.Wait() // 进程退出 + 内部 copy goroutine 结束
pw.Close() // 关闭写端,读端收到 EOF
wg.Wait() // 等日志全部刷完再往下走
cost := time.Since(start)
// 先判断超时(ctx.Err() 在超时/取消时非 nil
switch {
case errors.Is(ctx.Err(), context.DeadlineExceeded):
log.Printf("[TIMEOUT] 脚本执行超时 [%s] 已运行 %s 上限 %s", scriptPath, cost.Round(time.Second), scriptTimeout)
notifyResult(false, repoName, ref, commitID, cost, "执行超时,已强制终止")
if dashboard != nil {
dashboard.FinishExecution(task, statusTimeout, "执行超时,已强制终止", cost)
}
return
case err != nil:
log.Printf("[ERROR] 脚本执行失败 [%s] 耗时 %s: %v", scriptPath, cost.Round(time.Millisecond), err)
notifyResult(false, repoName, ref, commitID, cost, err.Error())
if dashboard != nil {
dashboard.FinishExecution(task, statusFailed, err.Error(), cost)
}
return
}
log.Printf("[SUCCESS] 脚本执行成功 [%s] 耗时 %s", scriptPath, cost.Round(time.Millisecond))
notifyResult(true, repoName, ref, commitID, cost, "执行成功")
if dashboard != nil {
dashboard.FinishExecution(task, statusSuccess, "执行成功", cost)
}
}