feat: 工具配置增强、站点拨测与日志中心多项改进
- 工具配置:新增卸载 API/UI、Go 工具链、流水线构建自动安装依赖 - 修复 ListTools VerifyTool 递归栈溢出导致获取工具配置失败 - Node.js Alpine 安装改用 unofficial-builds musl 预编译包与 index.json 解析 LTS - 站点拨测:详情页路由、告警自动恢复、并发拨测与默认可观测频率 - 日志中心:RBAC、查询构建器、ES/Loki 增强与审计 request/response 展示 - 发布配置:构建节点、流水线远程执行、Deploy RBAC 导航拆分
This commit is contained in:
1 parent
cde7bbe540
commit
7e1c8f583c
84 files changed
+7729
-1507
No files matched your search
@@ -58,9 +58,9 @@ func truncateAuditText(s string, n int) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// recordAgentAudit 记录 Agent 安装/卸载/升级操作审计 // Logan.Li
|
||||
// status: 1=成功, 0=失败;errMsg 记录失败原因(SSH 错误、脚本输出等),用于追溯"安装过程遇到什么问题"
|
||||
func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, resourceID string, status int, errMsg string) {
|
||||
// recordAgentAudit 记录 Agent 安装/卸载/升级操作审计(含命令输出与验证结果)// Logan.Li
|
||||
// status: 1=成功, 0=失败
|
||||
func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, resourceID string, status int, reqDetail, respDetail, errMsg string) {
|
||||
uid := ac.userID
|
||||
h.db.Create(&model.AuditLog{
|
||||
UserID: &uid,
|
||||
@@ -71,8 +71,10 @@ func (h *AgentInstallHandler) recordAgentAudit(ac auditCtx, action, resource, re
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
ResourceID: resourceID,
|
||||
Request: truncateAuditText(reqDetail, 2048),
|
||||
Response: truncateAuditText(respDetail, 8192),
|
||||
Status: status,
|
||||
Error: errMsg,
|
||||
Error: truncateAuditText(errMsg, 512),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -235,7 +237,7 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
client, err := sshDial(effectiveServer)
|
||||
if err != nil {
|
||||
log.Printf("[AgentInstall] SSH连接失败 %s: %v", server.IP, err)
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, resource, "", "SSH连接失败: "+formatSSHError(err))
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 500,
|
||||
Message: "SSH 连接失败: " + formatSSHError(err),
|
||||
@@ -296,7 +298,8 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
} else if strings.Contains(lowerOut, "下载失败") || strings.Contains(lowerOut, "failed to connect") || strings.Contains(lowerOut, "connection refused") {
|
||||
msg = "目标主机无法下载安装脚本,请检查后端地址 " + serverURL + " 是否可达"
|
||||
}
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, msg+": "+truncateAuditText(installOut, 300))
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0,
|
||||
fmt.Sprintf("server=%s arch=%s", serverURL, arch), installOut, msg)
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 500,
|
||||
Message: msg,
|
||||
@@ -309,23 +312,30 @@ func (h *AgentInstallHandler) InstallAgent(c *gin.Context) {
|
||||
verifyOut, _ := runSSHCommand(client, "systemctl is-active aiops-agent 2>/dev/null || echo 'not_found'")
|
||||
installed := contains(verifyOut, "active") || contains(verifyOut, "running")
|
||||
|
||||
var agentVer string
|
||||
if installed {
|
||||
// 只更新在线状态;版本号仅在能读到真实版本时才写入,未上报时不写假版本(等 Agent 首次上报真实数据)// Logan.Li
|
||||
updates := map[string]interface{}{
|
||||
"agent_status": "online",
|
||||
"last_check_at": time.Now(),
|
||||
}
|
||||
if agentVersion := detectAgentVersion(client); agentVersion != "" {
|
||||
updates["agent_version"] = agentVersion
|
||||
agentVer = detectAgentVersion(client)
|
||||
if agentVer != "" {
|
||||
updates["agent_version"] = agentVer
|
||||
}
|
||||
h.db.Model(&server).Updates(updates)
|
||||
}
|
||||
|
||||
// 审计:成功或"命令已执行但服务未运行"均记录,便于追溯 // Logan.Li
|
||||
reqDetail := fmt.Sprintf("server=%s arch=%s", serverURL, arch)
|
||||
respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut)
|
||||
if agentVer != "" {
|
||||
respDetail += "\n=== Agent版本 ===\n" + agentVer
|
||||
}
|
||||
if installed {
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 1, "")
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 1, reqDetail, respDetail, "")
|
||||
} else {
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, "安装命令已执行但服务未检测到运行: "+truncateAuditText(verifyOut, 300))
|
||||
h.recordAgentAudit(ac, "install", resource, resourceID, 0, reqDetail, respDetail, "安装命令已执行但服务未检测到运行")
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
@@ -434,20 +444,22 @@ func (h *AgentInstallHandler) BatchInstallAgent(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
installed, _, verifyOut, err := h.installOne(effectiveServer, serverURL, token)
|
||||
installed, installOut, verifyOut, err := h.installOne(effectiveServer, serverURL, token)
|
||||
res := server.Hostname + " (" + server.IP + ")"
|
||||
rid := strconv.Itoa(int(server.ID))
|
||||
reqDetail := fmt.Sprintf("server=%s host=%s", serverURL, res)
|
||||
if err != nil {
|
||||
log.Printf("[BatchInstall] %s (%s) 安装失败: %v", server.Hostname, server.IP, err)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 0, truncateAuditText(err.Error(), 500))
|
||||
h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, installOut, truncateAuditText(err.Error(), 500))
|
||||
} else {
|
||||
successCount++
|
||||
respDetail := fmt.Sprintf("=== 安装输出 ===\n%s\n=== 服务状态 ===\n%s", installOut, verifyOut)
|
||||
if installed {
|
||||
log.Printf("[BatchInstall] %s Agent 安装成功", server.Hostname)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 1, "")
|
||||
h.recordAgentAudit(ac, "install", res, rid, 1, reqDetail, respDetail, "")
|
||||
} else {
|
||||
log.Printf("[BatchInstall] %s Agent 安装命令已执行但服务未检测到运行", server.Hostname)
|
||||
h.recordAgentAudit(ac, "install", res, rid, 0, "服务未检测到运行: "+truncateAuditText(verifyOut, 300))
|
||||
h.recordAgentAudit(ac, "install", res, rid, 0, reqDetail, respDetail, "服务未检测到运行")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -498,14 +510,14 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
"agent_status": "offline", "agent_version": "",
|
||||
})
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "无SSH凭据,已标记离线")
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, resource, "无SSH凭据,已标记离线", "")
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已标记为离线(该主机无SSH凭据,无法远程卸载,请手动执行 systemctl stop aiops-agent)"})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := sshDial(server)
|
||||
if err != nil {
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 0, resource, "", "SSH连接失败: "+formatSSHError(err))
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "SSH连接失败: " + formatSSHError(err)})
|
||||
return
|
||||
}
|
||||
@@ -516,7 +528,7 @@ func (h *AgentInstallHandler) UninstallAgent(c *gin.Context) {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{
|
||||
"agent_status": "offline", "agent_version": "",
|
||||
})
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, "")
|
||||
h.recordAgentAudit(ac, "uninstall", resource, resourceID, 1, resource, "systemctl stop + rm binary", "")
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: fmt.Sprintf("已卸载 %s (%s) 的 Agent", server.Hostname, server.IP)})
|
||||
}
|
||||
@@ -548,21 +560,21 @@ func (h *AgentInstallHandler) BatchUninstallAgent(c *gin.Context) {
|
||||
if server.Username == "" || (server.Password == "" && server.SSHKey == "") {
|
||||
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
|
||||
log.Printf("[BatchUninstall] %s 无SSH凭据,已标记离线", server.Hostname)
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 1, "无SSH凭据,已标记离线")
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 1, res, "无SSH凭据,已标记离线", "")
|
||||
successCount++
|
||||
continue
|
||||
}
|
||||
client, err := sshDial(server)
|
||||
if err != nil {
|
||||
log.Printf("[BatchUninstall] %s SSH失败: %v", server.Hostname, err)
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 0, res, "", "SSH连接失败: "+formatSSHError(err))
|
||||
continue
|
||||
}
|
||||
runSSHCommand(client, "systemctl stop aiops-agent 2>/dev/null; systemctl disable aiops-agent 2>/dev/null; rm -f /usr/local/bin/aiops-agent /etc/systemd/system/aiops-agent.service; systemctl daemon-reload 2>/dev/null")
|
||||
client.Close()
|
||||
h.db.Model(&server).Updates(map[string]interface{}{"agent_status": "offline", "agent_version": ""})
|
||||
log.Printf("[BatchUninstall] %s Agent 已卸载", server.Hostname)
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 1, "")
|
||||
h.recordAgentAudit(ac, "uninstall", res, rid, 1, res, "systemctl stop + rm binary", "")
|
||||
successCount++
|
||||
}
|
||||
log.Printf("[BatchUninstall] 批量卸载完成: %d/%d 成功", successCount, len(req.ServerIDs))
|
||||
@@ -610,7 +622,7 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
client, err := sshDial(server)
|
||||
if err != nil {
|
||||
log.Printf("[BatchUpgrade] %s SSH连接失败: %v", server.Hostname, err)
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 0, "SSH连接失败: "+formatSSHError(err))
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 0, res, "", "SSH连接失败: "+formatSSHError(err))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -631,7 +643,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
|
||||
if err != nil || !contains(out, "UPGRADE_OK") {
|
||||
log.Printf("[BatchUpgrade] %s 升级失败: output=%s err=%v", server.Hostname, out, err)
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 0, "升级失败: "+truncateAuditText(out, 300))
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 0,
|
||||
fmt.Sprintf("server=%s binary=%s", serverURL, binaryFile), out, "升级失败")
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -647,7 +660,8 @@ func (h *AgentInstallHandler) BatchUpgradeAgent(c *gin.Context) {
|
||||
"last_check_at": time.Now(),
|
||||
})
|
||||
log.Printf("[BatchUpgrade] %s Agent 升级成功", server.Hostname)
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 1, "")
|
||||
h.recordAgentAudit(ac, "upgrade", res, rid, 1,
|
||||
fmt.Sprintf("server=%s binary=%s", serverURL, binaryFile), out, "")
|
||||
successCount++
|
||||
}
|
||||
log.Printf("[BatchUpgrade] 批量升级完成: %d/%d 成功", successCount, len(req.ServerIDs))
|
||||
|
||||
@@ -67,7 +67,7 @@ func (e *AlertEngine) Check() {
|
||||
e.checkMiddlewareAlerts()
|
||||
e.checkSNMPAlerts()
|
||||
e.checkSiteAlerts()
|
||||
e.autoResolveSiteSslAlerts()
|
||||
e.autoResolveSiteAlerts()
|
||||
e.checkLogAlertRules()
|
||||
e.autoResolveAlerts()
|
||||
e.checkLogAlertRecovery()
|
||||
|
||||
@@ -192,6 +192,7 @@ func (e *AlertEngine) evalSiteRule(rule model.MonitorAlertRule, task model.SiteT
|
||||
}
|
||||
|
||||
if !triggered {
|
||||
e.resolveActiveSiteAlerts(task.ID, rule.Metric)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -294,22 +295,79 @@ func extractHostMetricValue(report model.AgentReport, metric string) (float64, b
|
||||
}
|
||||
}
|
||||
|
||||
// autoResolveSiteSslAlerts 证书续期后自动恢复 SSL 过期告警 // Logan.Li
|
||||
func (e *AlertEngine) autoResolveSiteSslAlerts() {
|
||||
// resolveActiveSiteAlerts 指标恢复正常时关闭对应活跃告警 // Logan.Li
|
||||
func (e *AlertEngine) resolveActiveSiteAlerts(taskID uint, triggerType string) {
|
||||
now := time.Now()
|
||||
e.db.Model(&model.SiteAlert{}).
|
||||
Where("task_id = ? AND status = ? AND trigger_type = ?", taskID, model.SiteAlertStatusActive, triggerType).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.SiteAlertStatusResolved, "resolved_at": now,
|
||||
})
|
||||
}
|
||||
|
||||
// siteRuleStillTriggered 判断任务是否仍满足告警规则 // Logan.Li
|
||||
func (e *AlertEngine) siteRuleStillTriggered(rule model.MonitorAlertRule, task model.SiteTask) bool {
|
||||
switch rule.Metric {
|
||||
case "probe_fail":
|
||||
return task.LastResult == "fail" || task.LastResult == "timeout"
|
||||
case "probe_timeout":
|
||||
return task.LastResult == "timeout"
|
||||
case "http_status_error":
|
||||
return task.LastResult == "fail"
|
||||
case "response_time":
|
||||
return e.compareOperator(rule.Operator, task.AvgResponse, rule.Threshold)
|
||||
case "availability":
|
||||
return e.compareOperator(rule.Operator, task.SuccessRate, rule.Threshold)
|
||||
case "ssl_expiry_days":
|
||||
if !siteprobe.IsSSLProbeTask(task) {
|
||||
return false
|
||||
}
|
||||
var result model.SiteResult
|
||||
e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", task.ID).
|
||||
Order("checked_at DESC").First(&result)
|
||||
if result.ID == 0 {
|
||||
return false
|
||||
}
|
||||
return e.compareOperator(rule.Operator, float64(result.SSLDaysRemaining), rule.Threshold)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// autoResolveSiteAlerts 扫描活跃拨测告警,指标已恢复则自动关闭(含 SSL)// Logan.Li
|
||||
func (e *AlertEngine) autoResolveSiteAlerts() {
|
||||
var alerts []model.SiteAlert
|
||||
e.db.Where("status = ? AND trigger_type = ?", model.SiteAlertStatusActive, "ssl_expiry_days").Find(&alerts)
|
||||
e.db.Where("status = ?", model.SiteAlertStatusActive).Find(&alerts)
|
||||
if len(alerts) == 0 {
|
||||
return
|
||||
}
|
||||
taskCache := map[uint]model.SiteTask{}
|
||||
ruleCache := map[string]model.MonitorAlertRule{}
|
||||
now := time.Now()
|
||||
for _, alert := range alerts {
|
||||
var result model.SiteResult
|
||||
e.db.Where("task_id = ? AND ssl_cert_expiry IS NOT NULL", alert.TaskID).
|
||||
Order("checked_at DESC").First(&result)
|
||||
if result.ID == 0 {
|
||||
continue
|
||||
task, ok := taskCache[alert.TaskID]
|
||||
if !ok {
|
||||
var t model.SiteTask
|
||||
if e.db.First(&t, alert.TaskID).Error != nil {
|
||||
continue
|
||||
}
|
||||
task = t
|
||||
taskCache[alert.TaskID] = task
|
||||
}
|
||||
if float64(result.SSLDaysRemaining) >= 30 {
|
||||
rule, ok := ruleCache[alert.Title]
|
||||
if !ok {
|
||||
var r model.MonitorAlertRule
|
||||
e.db.Where("category = ? AND name = ?", model.MonitorAlertSite, alert.Title).First(&r)
|
||||
if r.ID == 0 {
|
||||
r.Metric = alert.TriggerType
|
||||
}
|
||||
rule = r
|
||||
ruleCache[alert.Title] = rule
|
||||
}
|
||||
if rule.Metric == "" {
|
||||
rule.Metric = alert.TriggerType
|
||||
}
|
||||
if !e.siteRuleStillTriggered(rule, task) {
|
||||
e.db.Model(&alert).Updates(map[string]interface{}{
|
||||
"status": model.SiteAlertStatusResolved, "resolved_at": now,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/remoteexec"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ListBuildNodes 构建节点列表 // Logan.Li
|
||||
func (h *PipelineHandler) ListBuildNodes(c *gin.Context) {
|
||||
masterCfg := h.ensureBuildMasterConfig()
|
||||
var nodes []model.BuildNode
|
||||
h.db.Order("id ASC").Find(&nodes)
|
||||
|
||||
// 填充运行时负载与服务器信息
|
||||
nodeMu.Lock()
|
||||
for i := range nodes {
|
||||
nodes[i].RunningCount = nodeRunningCount[nodes[i].ID]
|
||||
var srv model.Server
|
||||
if err := h.db.First(&srv, nodes[i].ServerID).Error; err == nil {
|
||||
nodes[i].Hostname = srv.Hostname
|
||||
nodes[i].IP = srv.IP
|
||||
}
|
||||
}
|
||||
masterRunning := nodeRunningCount[0]
|
||||
nodeMu.Unlock()
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
Data: map[string]interface{}{
|
||||
"list": nodes,
|
||||
"total": len(nodes),
|
||||
"master": map[string]interface{}{
|
||||
"name": "Master",
|
||||
"max_executors": masterCfg.MaxExecutors,
|
||||
"running_count": masterRunning,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// CreateBuildNode 创建构建节点 // Logan.Li
|
||||
func (h *PipelineHandler) CreateBuildNode(c *gin.Context) {
|
||||
var req model.CreateBuildNodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "节点名称不能为空"})
|
||||
return
|
||||
}
|
||||
var srv model.Server
|
||||
if err := h.db.First(&srv, req.ServerID).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "关联服务器不存在"})
|
||||
return
|
||||
}
|
||||
var dup int64
|
||||
h.db.Model(&model.BuildNode{}).Where("name = ?", req.Name).Count(&dup)
|
||||
if dup > 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "节点名称已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
node := model.BuildNode{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
ServerID: req.ServerID,
|
||||
Labels: req.Labels,
|
||||
MaxExecutors: req.MaxExecutors,
|
||||
WorkspacePath: req.WorkspacePath,
|
||||
Enabled: true,
|
||||
}
|
||||
if node.MaxExecutors <= 0 {
|
||||
node.MaxExecutors = 2
|
||||
}
|
||||
if strings.TrimSpace(node.WorkspacePath) == "" {
|
||||
node.WorkspacePath = "/data/aiops-workspace"
|
||||
}
|
||||
|
||||
if err := h.db.Create(&node).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败"})
|
||||
return
|
||||
}
|
||||
node.Hostname = srv.Hostname
|
||||
node.IP = srv.IP
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "创建成功", Data: node})
|
||||
}
|
||||
|
||||
// UpdateBuildNode 更新构建节点 // Logan.Li
|
||||
func (h *PipelineHandler) UpdateBuildNode(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var node model.BuildNode
|
||||
if err := h.db.First(&node, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "节点不存在"})
|
||||
return
|
||||
}
|
||||
var req model.UpdateBuildNodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误"})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != "" {
|
||||
updates["name"] = strings.TrimSpace(req.Name)
|
||||
}
|
||||
if req.Description != "" {
|
||||
updates["description"] = req.Description
|
||||
}
|
||||
if req.ServerID != nil {
|
||||
var srv model.Server
|
||||
if err := h.db.First(&srv, *req.ServerID).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "关联服务器不存在"})
|
||||
return
|
||||
}
|
||||
updates["server_id"] = *req.ServerID
|
||||
}
|
||||
if req.Labels != "" {
|
||||
updates["labels"] = req.Labels
|
||||
}
|
||||
if req.MaxExecutors > 0 {
|
||||
updates["max_executors"] = req.MaxExecutors
|
||||
}
|
||||
if req.WorkspacePath != "" {
|
||||
updates["workspace_path"] = req.WorkspacePath
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
updates["enabled"] = *req.Enabled
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: node})
|
||||
return
|
||||
}
|
||||
if err := h.db.Model(&node).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "更新失败"})
|
||||
return
|
||||
}
|
||||
h.db.First(&node, id)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功", Data: node})
|
||||
}
|
||||
|
||||
// DeleteBuildNode 删除构建节点 // Logan.Li
|
||||
func (h *PipelineHandler) DeleteBuildNode(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var inUse int64
|
||||
h.db.Model(&model.Pipeline{}).Where("build_node_id = ?", id).Count(&inUse)
|
||||
if inUse > 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "仍有流水线绑定此节点,请先解除关联"})
|
||||
return
|
||||
}
|
||||
if err := h.db.Delete(&model.BuildNode{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
|
||||
}
|
||||
|
||||
// TestBuildNode 测试构建节点 SSH 连通性 // Logan.Li
|
||||
func (h *PipelineHandler) TestBuildNode(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var node model.BuildNode
|
||||
if err := h.db.First(&node, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "节点不存在"})
|
||||
return
|
||||
}
|
||||
var srv model.Server
|
||||
if err := h.db.First(&srv, node.ServerID).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "关联服务器不存在"})
|
||||
return
|
||||
}
|
||||
cfg, err := buildSSHConfig(h.db, srv)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: map[string]interface{}{
|
||||
"success": false, "message": err.Error(),
|
||||
}})
|
||||
return
|
||||
}
|
||||
result, err := remoteexec.Exec(cfg, "echo aiops-node-ok && uname -a", 15*time.Second)
|
||||
if err != nil || !result.Success {
|
||||
msg := ""
|
||||
if result != nil {
|
||||
msg = result.Error
|
||||
if msg == "" {
|
||||
msg = result.Output
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: map[string]interface{}{
|
||||
"success": false, "message": msg,
|
||||
}})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: map[string]interface{}{
|
||||
"success": true, "message": strings.TrimSpace(result.Output),
|
||||
}})
|
||||
}
|
||||
|
||||
// UpdateBuildMaster 更新 Master 内置节点最大并发(1=串行按触发顺序排队) // Logan.Li
|
||||
func (h *PipelineHandler) UpdateBuildMaster(c *gin.Context) {
|
||||
var req model.UpdateBuildMasterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
cfg := h.setMasterMaxExecutors(req.MaxExecutors)
|
||||
nodeMu.Lock()
|
||||
running := nodeRunningCount[masterNodeID]
|
||||
nodeMu.Unlock()
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200, Message: "已更新",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Master", "max_executors": cfg.MaxExecutors, "running_count": running,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,13 +5,17 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
"ezops/pkg/logaccess"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -40,6 +44,7 @@ func (h *LogDSHandler) List(c *gin.Context) {
|
||||
if sources == nil {
|
||||
sources = []model.LogDataSource{}
|
||||
}
|
||||
sources = logaccess.FilterDataSources(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), sources)
|
||||
// 按项目惯例返回 {list,total},前端 listDatasources 取 .list // Logan.Li
|
||||
c.JSON(200, model.Response{Code: 200, Data: model.ListResponse{List: sources, Total: int64(len(sources))}})
|
||||
}
|
||||
@@ -268,6 +273,67 @@ func (h *LogDSHandler) ListIndices(c *gin.Context) {
|
||||
c.JSON(200, model.Response{Code: 200, Data: indices})
|
||||
}
|
||||
|
||||
// ListIndicesByID GET /api/v1/log-datasources/:id/indices — 按数据源 ID 拉取索引(查询面板用)// Logan.Li
|
||||
func (h *LogDSHandler) ListIndicesByID(c *gin.Context) {
|
||||
ds, err := loadLogDS(h, parseUintParam(c.Param("id")))
|
||||
if err != nil {
|
||||
c.JSON(404, model.Response{Code: 404, Message: "数据源不存在"})
|
||||
return
|
||||
}
|
||||
if ds.Type != "elasticsearch" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "仅 Elasticsearch 数据源支持索引列表"})
|
||||
return
|
||||
}
|
||||
filtered := logaccess.FilterDataSources(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), []model.LogDataSource{*ds})
|
||||
if len(filtered) == 0 {
|
||||
c.JSON(403, model.Response{Code: 403, Message: "无权访问该数据源"})
|
||||
return
|
||||
}
|
||||
indices, err := fetchESIndices(ds)
|
||||
if err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: indices})
|
||||
}
|
||||
|
||||
func fetchESIndices(ds *model.LogDataSource) ([]string, error) {
|
||||
client := newLogHTTPClient(ds.SkipTLSVerify, 15*time.Second)
|
||||
target := strings.TrimRight(ds.URL, "/") + "/_cat/indices?format=json&h=index"
|
||||
httpReq, err := http.NewRequest(http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyLogDSAuthV2(httpReq, ds)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询索引失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("ES 错误: %s", string(body))
|
||||
}
|
||||
var raw []map[string]interface{}
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("解析索引列表失败")
|
||||
}
|
||||
indices := make([]string, 0, len(raw))
|
||||
for _, entry := range raw {
|
||||
idx, ok := entry["index"].(string)
|
||||
if !ok || idx == "" || strings.HasPrefix(idx, ".") {
|
||||
continue
|
||||
}
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
return indices, nil
|
||||
}
|
||||
|
||||
func parseUintParam(s string) uint {
|
||||
n, _ := strconv.ParseUint(s, 10, 64)
|
||||
return uint(n)
|
||||
}
|
||||
|
||||
func testLogDSConnection(req model.TestLogDSReq) (bool, string) {
|
||||
client := newLogHTTPClient(req.SkipTLSVerify, 10*time.Second)
|
||||
url := strings.TrimRight(req.URL, "/")
|
||||
|
||||
+180
-20
@@ -11,21 +11,25 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
"ezops/pkg/logaccess"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LogProxyHandler Loki / ES 无状态查询代理
|
||||
// 实时转发请求并解析展示字段,不将日志写入 AIOps 数据库 // Logan.Li
|
||||
type LogProxyHandler struct {
|
||||
ds *LogDSHandler
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
const maxLogQueryLimit = 1000 // 单次查询返回日志行数硬上限(Loki limit / ES size 共用)// Logan.Li
|
||||
|
||||
func NewLogProxyHandler(ds *LogDSHandler) *LogProxyHandler {
|
||||
return &LogProxyHandler{ds: ds}
|
||||
func NewLogProxyHandler(ds *LogDSHandler, db *gorm.DB) *LogProxyHandler {
|
||||
return &LogProxyHandler{ds: ds, db: db}
|
||||
}
|
||||
|
||||
// QueryLoki POST /api/v1/logs/loki/query
|
||||
@@ -37,11 +41,19 @@ func (h *LogProxyHandler) QueryLoki(c *gin.Context) {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
Direction string `json:"direction"`
|
||||
Cursor string `json:"cursor"` // 纳秒时间戳,向后翻页时作为 end // Logan.Li
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{
|
||||
Engine: "loki", DatasourceID: req.DatasourceID, Query: req.Query,
|
||||
})
|
||||
if !allowed {
|
||||
c.JSON(403, model.Response{Code: 403, Message: denyMsg})
|
||||
return
|
||||
}
|
||||
ds, err := loadLogDS(h.ds, req.DatasourceID)
|
||||
if err != nil || ds.Type != "loki" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
|
||||
@@ -68,6 +80,11 @@ func (h *LogProxyHandler) QueryLoki(c *gin.Context) {
|
||||
start = t
|
||||
}
|
||||
}
|
||||
if req.Cursor != "" {
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(req.Cursor), 10, 64); err == nil && n > 0 {
|
||||
end = time.Unix(0, n)
|
||||
}
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("query", req.Query)
|
||||
@@ -94,8 +111,12 @@ func (h *LogProxyHandler) QueryLoki(c *gin.Context) {
|
||||
var raw map[string]interface{}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
entries := flattenLokiStreams(raw)
|
||||
nextCursor := ""
|
||||
if len(entries) > 0 && len(entries) >= req.Limit {
|
||||
nextCursor = lokiEntryCursor(entries[len(entries)-1].Timestamp)
|
||||
}
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"entries": entries, "total": len(entries),
|
||||
"entries": entries, "total": len(entries), "has_more": nextCursor != "", "next_cursor": nextCursor,
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -109,23 +130,33 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
|
||||
Size int `json:"size"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
SearchAfter []interface{} `json:"search_after"` // ES 深分页游标 // Logan.Li
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
index := req.Index
|
||||
if index == "" {
|
||||
if dsPreview, err := loadLogDS(h.ds, req.DatasourceID); err == nil {
|
||||
index = dsPreview.DefaultIndex
|
||||
}
|
||||
}
|
||||
if index == "" {
|
||||
index = "*"
|
||||
}
|
||||
allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{
|
||||
Engine: "elasticsearch", DatasourceID: req.DatasourceID, Index: index, Query: req.Query,
|
||||
})
|
||||
if !allowed {
|
||||
c.JSON(403, model.Response{Code: 403, Message: denyMsg})
|
||||
return
|
||||
}
|
||||
ds, err := loadLogDS(h.ds, req.DatasourceID)
|
||||
if err != nil || ds.Type != "elasticsearch" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"})
|
||||
return
|
||||
}
|
||||
index := req.Index
|
||||
if index == "" {
|
||||
index = ds.DefaultIndex
|
||||
}
|
||||
if index == "" {
|
||||
index = "*"
|
||||
}
|
||||
if req.Size <= 0 {
|
||||
req.Size = 200
|
||||
}
|
||||
@@ -140,7 +171,8 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
|
||||
// ES 7+ 默认 track_total_hits 为 10000,必须显式开启才能返回真实命中总数 // Logan.Li
|
||||
"track_total_hits": true,
|
||||
"sort": []interface{}{
|
||||
map[string]interface{}{"_score": map[string]string{"order": "desc"}},
|
||||
map[string]interface{}{"@timestamp": map[string]string{"order": "desc", "unmapped_type": "date"}},
|
||||
map[string]interface{}{"_id": map[string]string{"order": "desc"}},
|
||||
},
|
||||
"query": map[string]interface{}{
|
||||
"bool": map[string]interface{}{
|
||||
@@ -206,6 +238,9 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(req.SearchAfter) > 0 {
|
||||
searchBody["search_after"] = req.SearchAfter
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(searchBody)
|
||||
client := newLogHTTPClient(ds.SkipTLSVerify, 60*time.Second)
|
||||
@@ -226,10 +261,11 @@ func (h *LogProxyHandler) QueryES(c *gin.Context) {
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
entries := flattenESEntries(raw)
|
||||
entries, nextSearchAfter := flattenESEntries(raw)
|
||||
total := esHitsTotal(raw)
|
||||
hasMore := len(entries) >= req.Size && len(nextSearchAfter) > 0
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{
|
||||
"entries": entries, "total": total,
|
||||
"entries": entries, "total": total, "has_more": hasMore, "search_after": nextSearchAfter,
|
||||
}})
|
||||
}
|
||||
|
||||
@@ -258,6 +294,28 @@ func logProxyErrCategory(status int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// formatLokiTimestamp 将 Loki 纳秒/秒级(含科学计数法)时间戳转为 RFC3339 // Logan.Li
|
||||
func formatLokiTimestamp(ts string) string {
|
||||
ts = strings.TrimSpace(ts)
|
||||
if f, err := strconv.ParseFloat(ts, 64); err == nil {
|
||||
if strings.Contains(ts, ".") || strings.Contains(strings.ToLower(ts), "e") {
|
||||
sec := int64(f)
|
||||
nsec := int64((f - float64(sec)) * 1e9)
|
||||
return time.Unix(sec, nsec).UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
if n, err := strconv.ParseInt(ts, 10, 64); err == nil {
|
||||
if n > 1e15 {
|
||||
return time.Unix(0, n).UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
if n > 1e12 {
|
||||
return time.UnixMilli(n).UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return time.Unix(n, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
func parseLogTime(s string) (time.Time, error) {
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
if n > 1e15 {
|
||||
@@ -301,7 +359,7 @@ func flattenLokiStreams(raw map[string]interface{}) []logEntryRow {
|
||||
if len(pair) < 2 {
|
||||
continue
|
||||
}
|
||||
ts := fmt.Sprintf("%v", pair[0])
|
||||
ts := formatLokiTimestamp(fmt.Sprintf("%v", pair[0]))
|
||||
msg := fmt.Sprintf("%v", pair[1])
|
||||
out = append(out, logEntryRow{
|
||||
Timestamp: ts,
|
||||
@@ -315,18 +373,19 @@ func flattenLokiStreams(raw map[string]interface{}) []logEntryRow {
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenESEntries(raw map[string]interface{}) []logEntryRow {
|
||||
func flattenESEntries(raw map[string]interface{}) ([]logEntryRow, []interface{}) {
|
||||
out := []logEntryRow{}
|
||||
var nextAfter []interface{}
|
||||
hitsWrap, _ := raw["hits"].(map[string]interface{})
|
||||
hits, _ := hitsWrap["hits"].([]interface{})
|
||||
for _, h := range hits {
|
||||
for i, h := range hits {
|
||||
doc, _ := h.(map[string]interface{})
|
||||
src, _ := doc["_source"].(map[string]interface{})
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
msg := pickESMessage(src)
|
||||
level, _ := src["level"].(string)
|
||||
level := pickESLevel(src)
|
||||
ts, _ := src["@timestamp"].(string)
|
||||
if ts == "" {
|
||||
ts = fmt.Sprintf("%v", src["timestamp"])
|
||||
@@ -336,10 +395,63 @@ func flattenESEntries(raw map[string]interface{}) []logEntryRow {
|
||||
Timestamp: ts,
|
||||
Level: level,
|
||||
Message: msg,
|
||||
Labels: extractESLabels(src),
|
||||
Raw: string(rawBytes),
|
||||
})
|
||||
if i == len(hits)-1 {
|
||||
if sortVals, ok := doc["sort"].([]interface{}); ok {
|
||||
nextAfter = sortVals
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, nextAfter
|
||||
}
|
||||
|
||||
// lokiEntryCursor 从 RFC3339 时间戳反推纳秒游标 // Logan.Li
|
||||
func lokiEntryCursor(ts string) string {
|
||||
if t, err := time.Parse(time.RFC3339Nano, ts); err == nil {
|
||||
return strconv.FormatInt(t.UnixNano(), 10)
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, ts); err == nil {
|
||||
return strconv.FormatInt(t.UnixNano(), 10)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractESLabels 从 _source 提取常用字段作为 labels(便于字段过滤)// Logan.Li
|
||||
func extractESLabels(src map[string]interface{}) map[string]string {
|
||||
labels := map[string]string{}
|
||||
for _, key := range []string{"app", "service", "service_name", "host.name", "kubernetes.namespace", "namespace", "container.name", "level"} {
|
||||
if v := pickESField(src, key); v != "" {
|
||||
labels[key] = v
|
||||
}
|
||||
}
|
||||
if lv := pickESLevel(src); lv != "" {
|
||||
labels["level"] = lv
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func pickESField(src map[string]interface{}, key string) string {
|
||||
if strings.Contains(key, ".") {
|
||||
parts := strings.Split(key, ".")
|
||||
cur := interface{}(src)
|
||||
for _, p := range parts {
|
||||
m, ok := cur.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur, ok = m[p]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return stringifyESField(cur)
|
||||
}
|
||||
if v, ok := src[key]; ok {
|
||||
return stringifyESField(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// esHitsTotal 从 ES 响应中提取真实命中总数(兼容 ES 7+ total.value 与旧版 total)// Logan.Li
|
||||
@@ -356,10 +468,58 @@ func esHitsTotal(raw map[string]interface{}) int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// pickESLevel 提取日志级别(兼容顶层 level 与 ECS/Filebeat 的 log.level) // Logan.Li
|
||||
func pickESLevel(src map[string]interface{}) string {
|
||||
if v, ok := src["level"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
if v, ok := src["log.level"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
if logObj, ok := src["log"].(map[string]interface{}); ok {
|
||||
if v, ok := logObj["level"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stringifyESField 将 ES 字段值转为展示字符串 // Logan.Li
|
||||
func stringifyESField(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
return fmt.Sprintf("%v", t)
|
||||
case bool:
|
||||
return fmt.Sprintf("%t", t)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// pickESMessage 提取日志正文(兼容 message / ECS log.original / event.original) // Logan.Li
|
||||
func pickESMessage(src map[string]interface{}) string {
|
||||
for _, key := range []string{"message", "msg", "log", "content"} {
|
||||
for _, key := range []string{"message", "msg", "content", "event.original"} {
|
||||
if v, ok := src[key]; ok {
|
||||
return fmt.Sprintf("%v", v)
|
||||
if s := stringifyESField(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
if logObj, ok := src["log"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"original", "message"} {
|
||||
if v, ok := logObj[key]; ok {
|
||||
if s := stringifyESField(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 顶层 log 为字符串时(部分旧索引)
|
||||
if v, ok := src["log"]; ok {
|
||||
if s := stringifyESField(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
b, _ := json.Marshal(src)
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/middleware"
|
||||
"ezops/model"
|
||||
"ezops/pkg/logaccess"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 探索类 API(标签浏览 / 直方图)与 log_proxy.go 查询代理拆分 // Logan.Li
|
||||
|
||||
// ListLokiLabels GET /api/v1/logs/loki/labels?datasource_id=&start=&end=
|
||||
func (h *LogProxyHandler) ListLokiLabels(c *gin.Context) {
|
||||
dsID, _ := strconv.ParseUint(c.Query("datasource_id"), 10, 64)
|
||||
if dsID == 0 {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "datasource_id 必填"})
|
||||
return
|
||||
}
|
||||
ds, err := loadLogDS(h.ds, uint(dsID))
|
||||
if err != nil || ds.Type != "loki" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
|
||||
return
|
||||
}
|
||||
start, end := parseLogTimeRange(c.Query("start"), c.Query("end"))
|
||||
params := url.Values{}
|
||||
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
|
||||
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
|
||||
client := newLogHTTPClient(ds.SkipTLSVerify, 15*time.Second)
|
||||
target := strings.TrimRight(ds.URL, "/") + "/loki/api/v1/labels?" + params.Encode()
|
||||
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
|
||||
applyLogDSAuthV2(httpReq, ds)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "查询标签失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "Loki 错误: " + string(body)})
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
labels := extractStringSlice(raw["data"])
|
||||
c.JSON(200, model.Response{Code: 200, Data: labels})
|
||||
}
|
||||
|
||||
// ListLokiLabelValues GET /api/v1/logs/loki/labels/:name/values
|
||||
func (h *LogProxyHandler) ListLokiLabelValues(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
dsID, _ := strconv.ParseUint(c.Query("datasource_id"), 10, 64)
|
||||
if dsID == 0 || name == "" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "datasource_id 与 label 名必填"})
|
||||
return
|
||||
}
|
||||
ds, err := loadLogDS(h.ds, uint(dsID))
|
||||
if err != nil || ds.Type != "loki" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
|
||||
return
|
||||
}
|
||||
start, end := parseLogTimeRange(c.Query("start"), c.Query("end"))
|
||||
params := url.Values{}
|
||||
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
|
||||
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
|
||||
if q := extractLokiStreamSelector(strings.TrimSpace(c.Query("query"))); q != "" {
|
||||
params.Set("query", q)
|
||||
}
|
||||
client := newLogHTTPClient(ds.SkipTLSVerify, 15*time.Second)
|
||||
target := fmt.Sprintf("%s/loki/api/v1/label/%s/values?%s", strings.TrimRight(ds.URL, "/"), url.PathEscape(name), params.Encode())
|
||||
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
|
||||
applyLogDSAuthV2(httpReq, ds)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "查询标签值失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "Loki 错误: " + string(body)})
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
values := extractStringSlice(raw["data"])
|
||||
c.JSON(200, model.Response{Code: 200, Data: values})
|
||||
}
|
||||
|
||||
// LokiHistogram POST /api/v1/logs/loki/histogram — 服务端日志量直方图 // Logan.Li
|
||||
func (h *LogProxyHandler) LokiHistogram(c *gin.Context) {
|
||||
var req struct {
|
||||
DatasourceID uint `json:"datasource_id" binding:"required"`
|
||||
Query string `json:"query" binding:"required"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{
|
||||
Engine: "loki", DatasourceID: req.DatasourceID, Query: req.Query,
|
||||
})
|
||||
if !allowed {
|
||||
c.JSON(403, model.Response{Code: 403, Message: denyMsg})
|
||||
return
|
||||
}
|
||||
ds, err := loadLogDS(h.ds, req.DatasourceID)
|
||||
if err != nil || ds.Type != "loki" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "Loki 数据源无效"})
|
||||
return
|
||||
}
|
||||
start, end := parseLogTimeRange(req.Start, req.End)
|
||||
step := pickHistogramStep(end.Sub(start))
|
||||
metricQuery := fmt.Sprintf("sum(count_over_time(%s[%s]))", req.Query, step)
|
||||
params := url.Values{}
|
||||
params.Set("query", metricQuery)
|
||||
params.Set("start", fmt.Sprintf("%d", start.UnixNano()))
|
||||
params.Set("end", fmt.Sprintf("%d", end.UnixNano()))
|
||||
params.Set("step", step)
|
||||
client := newLogHTTPClient(ds.SkipTLSVerify, 30*time.Second)
|
||||
target := strings.TrimRight(ds.URL, "/") + "/loki/api/v1/query_range?" + params.Encode()
|
||||
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
|
||||
applyLogDSAuthV2(httpReq, ds)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "直方图查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "Loki 错误: " + string(body)})
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
buckets := flattenLokiMetricSeries(raw)
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{"buckets": buckets}})
|
||||
}
|
||||
|
||||
// ESHistogram POST /api/v1/logs/elasticsearch/histogram — ES date_histogram 聚合 // Logan.Li
|
||||
func (h *LogProxyHandler) ESHistogram(c *gin.Context) {
|
||||
var req struct {
|
||||
DatasourceID uint `json:"datasource_id" binding:"required"`
|
||||
Index string `json:"index"`
|
||||
Query string `json:"query"`
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(400, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
index := req.Index
|
||||
if index == "" {
|
||||
if dsPreview, err := loadLogDS(h.ds, req.DatasourceID); err == nil {
|
||||
index = dsPreview.DefaultIndex
|
||||
}
|
||||
}
|
||||
if index == "" {
|
||||
index = "*"
|
||||
}
|
||||
allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{
|
||||
Engine: "elasticsearch", DatasourceID: req.DatasourceID, Index: index, Query: req.Query,
|
||||
})
|
||||
if !allowed {
|
||||
c.JSON(403, model.Response{Code: 403, Message: denyMsg})
|
||||
return
|
||||
}
|
||||
ds, err := loadLogDS(h.ds, req.DatasourceID)
|
||||
if err != nil || ds.Type != "elasticsearch" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"})
|
||||
return
|
||||
}
|
||||
start, end := parseLogTimeRange(req.Start, req.End)
|
||||
interval := pickHistogramStep(end.Sub(start))
|
||||
searchBody := buildESHistogramBody(req.Query, start, end, interval)
|
||||
payload, _ := json.Marshal(searchBody)
|
||||
client := newLogHTTPClient(ds.SkipTLSVerify, 30*time.Second)
|
||||
target := fmt.Sprintf("%s/%s/_search", strings.TrimRight(ds.URL, "/"), strings.Trim(index, "/"))
|
||||
httpReq, _ := http.NewRequest(http.MethodPost, target, bytes.NewReader(payload))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
applyLogDSAuthV2(httpReq, ds)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "直方图查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "ES 错误: " + string(body)})
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
buckets := flattenESHistogram(raw)
|
||||
c.JSON(200, model.Response{Code: 200, Data: gin.H{"buckets": buckets}})
|
||||
}
|
||||
|
||||
func parseLogTimeRange(startStr, endStr string) (time.Time, time.Time) {
|
||||
end := time.Now()
|
||||
start := end.Add(-1 * time.Hour)
|
||||
if endStr != "" {
|
||||
if t, e := parseLogTime(endStr); e == nil {
|
||||
end = t
|
||||
}
|
||||
}
|
||||
if startStr != "" {
|
||||
if t, e := parseLogTime(startStr); e == nil {
|
||||
start = t
|
||||
}
|
||||
}
|
||||
return start, end
|
||||
}
|
||||
|
||||
func pickHistogramStep(d time.Duration) string {
|
||||
switch {
|
||||
case d <= 15*time.Minute:
|
||||
return "30s"
|
||||
case d <= time.Hour:
|
||||
return "1m"
|
||||
case d <= 6*time.Hour:
|
||||
return "5m"
|
||||
case d <= 24*time.Hour:
|
||||
return "15m"
|
||||
case d <= 7*24*time.Hour:
|
||||
return "1h"
|
||||
default:
|
||||
return "6h"
|
||||
}
|
||||
}
|
||||
|
||||
func extractStringSlice(v interface{}) []string {
|
||||
arr, _ := v.([]interface{})
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, item := range arr {
|
||||
out = append(out, fmt.Sprintf("%v", item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flattenLokiMetricSeries(raw map[string]interface{}) []map[string]interface{} {
|
||||
out := []map[string]interface{}{}
|
||||
data, _ := raw["data"].(map[string]interface{})
|
||||
result, _ := data["result"].([]interface{})
|
||||
for _, item := range result {
|
||||
series, _ := item.(map[string]interface{})
|
||||
values, _ := series["values"].([]interface{})
|
||||
for _, val := range values {
|
||||
pair, _ := val.([]interface{})
|
||||
if len(pair) < 2 {
|
||||
continue
|
||||
}
|
||||
ts := formatLokiMetricTimestamp(pair[0])
|
||||
cnt, _ := strconv.ParseFloat(fmt.Sprintf("%v", pair[1]), 64)
|
||||
out = append(out, map[string]interface{}{
|
||||
"timestamp": ts,
|
||||
"count": int64(cnt),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildESHistogramBody(query string, start, end time.Time, interval string) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"size": 0,
|
||||
"query": map[string]interface{}{
|
||||
"bool": map[string]interface{}{
|
||||
"must": []interface{}{
|
||||
map[string]interface{}{"query_string": map[string]interface{}{"query": defaultESQuery(query)}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"aggs": map[string]interface{}{
|
||||
"log_volume": map[string]interface{}{
|
||||
"date_histogram": map[string]interface{}{
|
||||
"field": "@timestamp",
|
||||
"fixed_interval": interval,
|
||||
"min_doc_count": 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
rangeCond := map[string]interface{}{
|
||||
"bool": map[string]interface{}{
|
||||
"should": []interface{}{
|
||||
map[string]interface{}{"range": map[string]interface{}{"@timestamp": map[string]string{
|
||||
"gte": start.UTC().Format(time.RFC3339Nano),
|
||||
"lte": end.UTC().Format(time.RFC3339Nano),
|
||||
}}},
|
||||
map[string]interface{}{"range": map[string]interface{}{"timestamp": map[string]string{
|
||||
"gte": start.UTC().Format(time.RFC3339Nano),
|
||||
"lte": end.UTC().Format(time.RFC3339Nano),
|
||||
}}},
|
||||
},
|
||||
"minimum_should_match": 1,
|
||||
},
|
||||
}
|
||||
body["query"] = map[string]interface{}{
|
||||
"bool": map[string]interface{}{
|
||||
"must": []interface{}{body["query"], rangeCond},
|
||||
},
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func flattenESHistogram(raw map[string]interface{}) []map[string]interface{} {
|
||||
out := []map[string]interface{}{}
|
||||
aggs, _ := raw["aggregations"].(map[string]interface{})
|
||||
vol, _ := aggs["log_volume"].(map[string]interface{})
|
||||
buckets, _ := vol["buckets"].([]interface{})
|
||||
for _, b := range buckets {
|
||||
bucket, _ := b.(map[string]interface{})
|
||||
ts, _ := bucket["key_as_string"].(string)
|
||||
if ts == "" {
|
||||
if key, ok := bucket["key"].(float64); ok {
|
||||
ts = time.UnixMilli(int64(key)).UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
}
|
||||
cnt, _ := bucket["doc_count"].(float64)
|
||||
out = append(out, map[string]interface{}{"timestamp": ts, "count": int64(cnt)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ESMapping GET /api/v1/logs/elasticsearch/mapping?datasource_id=&index=
|
||||
func (h *LogProxyHandler) ESMapping(c *gin.Context) {
|
||||
dsID, _ := strconv.ParseUint(c.Query("datasource_id"), 10, 64)
|
||||
index := strings.TrimSpace(c.Query("index"))
|
||||
if dsID == 0 {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "datasource_id 必填"})
|
||||
return
|
||||
}
|
||||
if index == "" {
|
||||
index = "*"
|
||||
}
|
||||
allowed, denyMsg := logaccess.CanQuery(h.db, middleware.GetUserID(c), middleware.GetIsAdmin(c), logaccess.QueryContext{
|
||||
Engine: "elasticsearch", DatasourceID: uint(dsID), Index: index, Query: "*",
|
||||
})
|
||||
if !allowed {
|
||||
c.JSON(403, model.Response{Code: 403, Message: denyMsg})
|
||||
return
|
||||
}
|
||||
ds, err := loadLogDS(h.ds, uint(dsID))
|
||||
if err != nil || ds.Type != "elasticsearch" {
|
||||
c.JSON(400, model.Response{Code: 400, Message: "Elasticsearch 数据源无效"})
|
||||
return
|
||||
}
|
||||
client := newLogHTTPClient(ds.SkipTLSVerify, 20*time.Second)
|
||||
target := fmt.Sprintf("%s/%s/_mapping", strings.TrimRight(ds.URL, "/"), strings.Trim(index, "/"))
|
||||
httpReq, _ := http.NewRequest(http.MethodGet, target, nil)
|
||||
applyLogDSAuthV2(httpReq, ds)
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(502, model.Response{Code: 502, Message: "获取 mapping 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(logProxyErrStatus(resp.StatusCode), model.Response{Code: logProxyErrStatus(resp.StatusCode), Message: "ES 错误: " + string(body)})
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
_ = json.Unmarshal(body, &raw)
|
||||
fields := flattenESMapping(raw)
|
||||
c.JSON(200, model.Response{Code: 200, Data: fields})
|
||||
}
|
||||
|
||||
func flattenESMapping(raw map[string]interface{}) []map[string]string {
|
||||
out := []map[string]string{}
|
||||
seen := map[string]bool{}
|
||||
var walk func(prefix string, props map[string]interface{})
|
||||
walk = func(prefix string, props map[string]interface{}) {
|
||||
for name, node := range props {
|
||||
full := name
|
||||
if prefix != "" {
|
||||
full = prefix + "." + name
|
||||
}
|
||||
m, _ := node.(map[string]interface{})
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if sub, ok := m["properties"].(map[string]interface{}); ok {
|
||||
walk(full, sub)
|
||||
continue
|
||||
}
|
||||
if fields, ok := m["fields"].(map[string]interface{}); ok {
|
||||
walk(full, fields)
|
||||
}
|
||||
typ, _ := m["type"].(string)
|
||||
if typ == "" {
|
||||
typ = "object"
|
||||
}
|
||||
if !seen[full] {
|
||||
seen[full] = true
|
||||
out = append(out, map[string]string{"name": full, "type": mapESFieldType(typ)})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, idxDef := range raw {
|
||||
idxMap, _ := idxDef.(map[string]interface{})
|
||||
mappings, _ := idxMap["mappings"].(map[string]interface{})
|
||||
props, _ := mappings["properties"].(map[string]interface{})
|
||||
if props != nil {
|
||||
walk("", props)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mapESFieldType(esType string) string {
|
||||
switch esType {
|
||||
case "keyword", "text", "match_only_text":
|
||||
return "keyword"
|
||||
case "long", "integer", "short", "byte", "double", "float", "half_float", "scaled_float":
|
||||
return "long"
|
||||
case "date", "date_nanos":
|
||||
return "date"
|
||||
case "boolean":
|
||||
return "boolean"
|
||||
case "ip":
|
||||
return "ip"
|
||||
default:
|
||||
return "text"
|
||||
}
|
||||
}
|
||||
|
||||
// extractLokiStreamSelector 从 LogQL 提取 {...} 选择器(Label API 仅接受 stream selector)// Logan.Li
|
||||
func extractLokiStreamSelector(query string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
start := strings.Index(query, "{")
|
||||
if start < 0 {
|
||||
return ""
|
||||
}
|
||||
depth := 0
|
||||
for i := start; i < len(query); i++ {
|
||||
switch query[i] {
|
||||
case '{':
|
||||
depth++
|
||||
case '}':
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return query[start : i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatLokiMetricTimestamp 解析 Loki metric 序列时间戳(秒级 float / 科学计数法)// Logan.Li
|
||||
func formatLokiMetricTimestamp(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
sec := int64(t)
|
||||
nsec := int64((t - float64(sec)) * 1e9)
|
||||
return time.Unix(sec, nsec).UTC().Format(time.RFC3339)
|
||||
case json.Number:
|
||||
if f, err := t.Float64(); err == nil {
|
||||
sec := int64(f)
|
||||
return time.Unix(sec, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
return formatLokiTimestamp(fmt.Sprintf("%v", v))
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/logaccess"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// LogScopeRoleHandler 日志查询权限 // Logan.Li
|
||||
type LogScopeRoleHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLogScopeRoleHandler(db *gorm.DB) *LogScopeRoleHandler {
|
||||
return &LogScopeRoleHandler{db: db}
|
||||
}
|
||||
|
||||
// ListLogScopeRoles GET /log-scope-roles
|
||||
func (h *LogScopeRoleHandler) ListLogScopeRoles(c *gin.Context) {
|
||||
var roles []model.LogScopeRole
|
||||
h.db.Order("created_at DESC").Find(&roles)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: roles, Total: int64(len(roles))}})
|
||||
}
|
||||
|
||||
// CreateLogScopeRole POST /log-scope-roles
|
||||
func (h *LogScopeRoleHandler) CreateLogScopeRole(c *gin.Context) {
|
||||
var req model.CreateLogScopeRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := regexp.Compile(req.Pattern); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "正则表达式无效: " + err.Error()})
|
||||
return
|
||||
}
|
||||
engine := strings.TrimSpace(req.Engine)
|
||||
if engine == "" {
|
||||
engine = "*"
|
||||
}
|
||||
role := req.Role
|
||||
if role == "" {
|
||||
role = "viewer"
|
||||
}
|
||||
var dup int64
|
||||
h.db.Model(&model.LogScopeRole{}).Where("name = ? OR code = ?", req.Name, req.Code).Count(&dup)
|
||||
if dup > 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "名称或编码已存在"})
|
||||
return
|
||||
}
|
||||
item := model.LogScopeRole{
|
||||
Name: req.Name, Code: req.Code, Engine: engine, MatchField: req.MatchField,
|
||||
Pattern: req.Pattern, DatasourceID: req.DatasourceID, Role: role,
|
||||
Description: req.Description, Status: 1,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "创建失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "创建成功", Data: item})
|
||||
}
|
||||
|
||||
// UpdateLogScopeRole PUT /log-scope-roles/:id
|
||||
func (h *LogScopeRoleHandler) UpdateLogScopeRole(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var item model.LogScopeRole
|
||||
if err := h.db.First(&item, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "策略不存在"})
|
||||
return
|
||||
}
|
||||
var req model.UpdateLogScopeRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != "" {
|
||||
updates["name"] = req.Name
|
||||
}
|
||||
if req.Code != "" {
|
||||
updates["code"] = req.Code
|
||||
}
|
||||
if req.Engine != "" {
|
||||
updates["engine"] = req.Engine
|
||||
}
|
||||
if req.MatchField != "" {
|
||||
updates["match_field"] = req.MatchField
|
||||
}
|
||||
if req.Pattern != "" {
|
||||
if _, err := regexp.Compile(req.Pattern); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "正则无效"})
|
||||
return
|
||||
}
|
||||
updates["pattern"] = req.Pattern
|
||||
}
|
||||
if req.DatasourceID != nil {
|
||||
updates["datasource_id"] = req.DatasourceID
|
||||
}
|
||||
if req.Role != "" {
|
||||
updates["role"] = req.Role
|
||||
}
|
||||
if req.Description != "" {
|
||||
updates["description"] = req.Description
|
||||
}
|
||||
if req.Status != nil {
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
h.db.Model(&item).Updates(updates)
|
||||
}
|
||||
h.db.First(&item, id)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "更新成功", Data: item})
|
||||
}
|
||||
|
||||
// DeleteLogScopeRole DELETE /log-scope-roles/:id
|
||||
func (h *LogScopeRoleHandler) DeleteLogScopeRole(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
h.db.Where("log_scope_role_id = ?", id).Delete(&model.UserLogScopeRole{})
|
||||
if err := h.db.Delete(&model.LogScopeRole{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "删除失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "删除成功"})
|
||||
}
|
||||
|
||||
// ListUserLogScopeRoles GET /log-scope-roles/users/:id
|
||||
func (h *LogScopeRoleHandler) ListUserLogScopeRoles(c *gin.Context) {
|
||||
uid, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var rows []model.UserLogScopeRole
|
||||
h.db.Preload("LogScopeRole").Where("user_id = ?", uid).Find(&rows)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: rows, Total: int64(len(rows))}})
|
||||
}
|
||||
|
||||
// AssignLogScopeRole POST /log-scope-roles/users/:id
|
||||
func (h *LogScopeRoleHandler) AssignLogScopeRole(c *gin.Context) {
|
||||
uid, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || uid == 0 {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
var req model.AssignLogScopeRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
var role model.LogScopeRole
|
||||
if err := h.db.First(&role, req.LogScopeRoleID).Error; err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "策略不存在"})
|
||||
return
|
||||
}
|
||||
var existing model.UserLogScopeRole
|
||||
if err := h.db.Where("user_id = ? AND log_scope_role_id = ?", uid, req.LogScopeRoleID).Take(&existing).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "已分配该策略"})
|
||||
return
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "查询失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
row := model.UserLogScopeRole{UserID: uint(uid), LogScopeRoleID: req.LogScopeRoleID}
|
||||
if err := h.db.Create(&row).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "分配失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
h.db.Preload("LogScopeRole").Take(&row, row.ID)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "分配成功", Data: row})
|
||||
}
|
||||
|
||||
// RemoveLogScopeRole DELETE /log-scope-roles/users/:userId/:assignId
|
||||
func (h *LogScopeRoleHandler) RemoveLogScopeRole(c *gin.Context) {
|
||||
assignID, _ := strconv.ParseUint(c.Param("assignId"), 10, 64)
|
||||
h.db.Delete(&model.UserLogScopeRole{}, assignID)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已撤销"})
|
||||
}
|
||||
|
||||
// PreviewLogScope POST /log-scope-roles/preview
|
||||
func (h *LogScopeRoleHandler) PreviewLogScope(c *gin.Context) {
|
||||
var req model.PreviewLogScopeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
var roles []model.LogScopeRole
|
||||
h.db.Where("status = 1").Find(&roles)
|
||||
qc := logaccess.QueryContext{
|
||||
Engine: req.Engine, DatasourceID: req.DatasourceID,
|
||||
Index: req.Index, Query: req.Query,
|
||||
}
|
||||
matched := logaccess.MatchRolesForPreview(roles, qc, req.SampleValue)
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||
"allowed": len(matched) > 0,
|
||||
"matched": matched,
|
||||
"total": len(matched),
|
||||
}})
|
||||
}
|
||||
@@ -61,7 +61,9 @@ type PipelineHandler struct {
|
||||
|
||||
// NewPipelineHandler 创建流水线处理器
|
||||
func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache, ti *toolinstaller.ToolInstaller) *PipelineHandler {
|
||||
return &PipelineHandler{db: db, cache: cache, toolInstaller: ti}
|
||||
h := &PipelineHandler{db: db, cache: cache, toolInstaller: ti}
|
||||
h.ensureBuildMasterConfig()
|
||||
return h
|
||||
}
|
||||
|
||||
// PipelineListItem 列表项(含最近一次运行摘要)// Logan.Li
|
||||
@@ -221,9 +223,13 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
|
||||
Variables: req.Variables,
|
||||
DeployMode: req.DeployMode,
|
||||
DeployTarget: req.DeployTarget,
|
||||
ServerIDs: req.ServerIDs,
|
||||
K8sClusterID: req.K8sClusterID,
|
||||
K8sNamespace: req.K8sNamespace,
|
||||
ServerIDs: req.ServerIDs,
|
||||
DeployRemotePath: req.DeployRemotePath,
|
||||
DeployScript: req.DeployScript,
|
||||
K8sClusterID: req.K8sClusterID,
|
||||
K8sNamespace: req.K8sNamespace,
|
||||
K8sDeployment: req.K8sDeployment,
|
||||
K8sContainerName: req.K8sContainerName,
|
||||
RepoID: req.RepoID,
|
||||
RegistryID: req.RegistryID,
|
||||
GitCredentialID: req.GitCredentialID,
|
||||
@@ -239,6 +245,7 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
|
||||
CachePaths: req.CachePaths,
|
||||
// 并发控制
|
||||
MaxExecutors: req.MaxExecutors,
|
||||
BuildNodeID: req.BuildNodeID,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
@@ -306,7 +313,11 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
|
||||
if req.DeployMode != "" { updates["deploy_mode"] = req.DeployMode }
|
||||
if req.DeployTarget != "" { updates["deploy_target"] = req.DeployTarget }
|
||||
if req.ServerIDs != "" { updates["server_ids"] = req.ServerIDs }
|
||||
if req.DeployRemotePath != "" { updates["deploy_remote_path"] = req.DeployRemotePath }
|
||||
if req.DeployScript != "" { updates["deploy_script"] = req.DeployScript }
|
||||
if req.K8sNamespace != "" { updates["k8s_namespace"] = req.K8sNamespace }
|
||||
if req.K8sDeployment != "" { updates["k8s_deployment"] = req.K8sDeployment }
|
||||
if req.K8sContainerName != "" { updates["k8s_container_name"] = req.K8sContainerName }
|
||||
if req.K8sClusterID != nil { updates["k8s_cluster_id"] = req.K8sClusterID }
|
||||
if req.RepoID != nil { updates["repo_id"] = req.RepoID }
|
||||
if req.RegistryID != nil { updates["registry_id"] = req.RegistryID }
|
||||
@@ -321,6 +332,7 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
|
||||
if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths }
|
||||
if req.Enabled != nil { updates["enabled"] = req.Enabled }
|
||||
if req.MaxExecutors > 0 { updates["max_executors"] = req.MaxExecutors }
|
||||
if req.BuildNodeID != nil { updates["build_node_id"] = req.BuildNodeID }
|
||||
|
||||
if err := h.db.Model(&pipeline).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Response{
|
||||
@@ -543,7 +555,7 @@ func (h *PipelineHandler) WebhookTrigger(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
go h.executePipeline(pipeline, run)
|
||||
go h.tryExecuteOrQueue(pipeline, run)
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{
|
||||
Code: 200,
|
||||
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/remoteexec"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -38,8 +40,9 @@ func init() {
|
||||
logger, _ = zap.NewProduction()
|
||||
}
|
||||
|
||||
// executePipeline 后台执行流水线(goroutine)— V2 Redis 版
|
||||
func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.PipelineRun) {
|
||||
// executePipeline 后台执行流水线(goroutine)— V2 Redis 版,支持 Master/SSH 构建节点 // Logan.Li
|
||||
func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.PipelineRun, nodeID uint) {
|
||||
defer h.releaseBuildNode(nodeID)
|
||||
runID := run.ID
|
||||
ctx := context.Background()
|
||||
logKey := fmt.Sprintf("pipeline:run:%d:logs", runID)
|
||||
@@ -129,15 +132,47 @@ func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.Pip
|
||||
}
|
||||
ts := time.Now()
|
||||
tsDir := fmt.Sprintf("%d%02d%02d%02d%02d", ts.Year(), ts.Month(), ts.Day(), ts.Hour(), ts.Minute())
|
||||
workspace := fmt.Sprintf("/app/workspace/%s-%s", repoName, pipeline.Code)
|
||||
localWorkspace := fmt.Sprintf("/app/workspace/%s-%s", repoName, pipeline.Code)
|
||||
workspace := localWorkspace
|
||||
|
||||
os.RemoveAll(workspace)
|
||||
os.MkdirAll(workspace, 0755)
|
||||
// 保存 workspace 路径到运行记录,方便终端查看
|
||||
h.db.Model(&model.PipelineRun{}).Where("id = ?", runID).Update("workspace_path", workspace)
|
||||
execCtx, err := h.buildExecCtx(nodeID, workspace)
|
||||
if err != nil {
|
||||
log("error", "init", fmt.Sprintf("构建执行上下文失败: %v", err))
|
||||
updateStatus("failed", 0)
|
||||
updatePipelineStats("failed")
|
||||
h.dequeueNext(pipeline.ID)
|
||||
h.flushLogsToDB(ctx, runID, logKey)
|
||||
return
|
||||
}
|
||||
if nodeID > 0 {
|
||||
var bn model.BuildNode
|
||||
if h.db.First(&bn, nodeID).Error == nil {
|
||||
workspace = remoteWorkspacePath(bn, repoName, pipeline.Code)
|
||||
}
|
||||
}
|
||||
execCtx.Workspace = workspace
|
||||
|
||||
if err := h.prepareWorkspace(execCtx, log); err != nil {
|
||||
log("error", "init", fmt.Sprintf("初始化工作区失败: %v", err))
|
||||
updateStatus("failed", 0)
|
||||
updatePipelineStats("failed")
|
||||
h.dequeueNext(pipeline.ID)
|
||||
h.flushLogsToDB(ctx, runID, logKey)
|
||||
return
|
||||
}
|
||||
|
||||
runUpdates := map[string]interface{}{
|
||||
"workspace_path": workspace,
|
||||
"build_node_name": execCtx.NodeName,
|
||||
}
|
||||
if nodeID > 0 {
|
||||
runUpdates["build_node_id"] = nodeID
|
||||
}
|
||||
h.db.Model(&model.PipelineRun{}).Where("id = ?", runID).Updates(runUpdates)
|
||||
|
||||
imageTag := fmt.Sprintf("%s-%s", repoName, tsDir)
|
||||
|
||||
log("info", "init", fmt.Sprintf("执行节点: %s", execCtx.NodeName))
|
||||
log("info", "init", fmt.Sprintf("触发方式: 手动 (admin)"))
|
||||
log("info", "init", fmt.Sprintf("分支: %s | 镜像标签: %s", branch, imageTag))
|
||||
|
||||
@@ -159,17 +194,31 @@ func (h *PipelineHandler) executePipeline(pipeline model.Pipeline, run model.Pip
|
||||
var err error
|
||||
switch stage.Type {
|
||||
case "checkout":
|
||||
err = h.runCheckout(gitURL, branch, workspace, log)
|
||||
err = h.runCheckout(execCtx, gitURL, branch, log)
|
||||
case "build":
|
||||
err = h.runBuild(workspace, pipeline.Dockerfile, log)
|
||||
err = h.runBuild(execCtx, pipeline.Dockerfile, log)
|
||||
case "test":
|
||||
err = h.runTest(workspace, log)
|
||||
err = h.runTest(execCtx, log)
|
||||
case "image":
|
||||
err = h.runDockerBuild(workspace, pipeline, imageTag, log)
|
||||
err = h.runDockerBuild(execCtx, pipeline, imageTag, log)
|
||||
case "deploy":
|
||||
err = h.runDeploy(workspace, pipeline, imageTag, log)
|
||||
deployWS := execCtx.Workspace
|
||||
if execCtx.Remote {
|
||||
if syncErr := h.syncRemoteWorkspaceToLocal(execCtx, localWorkspace, log); syncErr != nil {
|
||||
err = syncErr
|
||||
} else {
|
||||
deployWS = localWorkspace
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = h.runDeploy(deployWS, pipeline, imageTag, log)
|
||||
}
|
||||
case "verify":
|
||||
err = h.runVerify(workspace, log)
|
||||
verifyWS := execCtx.Workspace
|
||||
if execCtx.Remote {
|
||||
verifyWS = localWorkspace
|
||||
}
|
||||
err = h.runVerify(verifyWS, log)
|
||||
default:
|
||||
log("info", stage.Name, "跳过未知阶段类型: "+stage.Type)
|
||||
}
|
||||
@@ -214,25 +263,26 @@ func (h *PipelineHandler) flushLogsToDB(ctx context.Context, runID uint, logKey
|
||||
h.cache.Del(ctx, logKey)
|
||||
}
|
||||
|
||||
// runCheckout git clone
|
||||
func (h *PipelineHandler) runCheckout(url, branch, workspace string, logFn func(string, string, string)) error {
|
||||
// runCheckout git clone(Master 或远程构建节点) // Logan.Li
|
||||
func (h *PipelineHandler) runCheckout(execCtx *PipelineExecCtx, url, branch string, logFn func(string, string, string)) error {
|
||||
if url == "" {
|
||||
logFn("warn", "代码检出", "没有配置 Git URL,跳过代码检出")
|
||||
return nil
|
||||
}
|
||||
logFn("info", "代码检出", "> Git 拉取 "+url+" (分支: "+branch+")")
|
||||
|
||||
// U4: 确保工具可用(slim 镜像模式下 git 为懒加载)// Logan.Li
|
||||
if h.toolInstaller != nil {
|
||||
if !execCtx.Remote && h.toolInstaller != nil {
|
||||
if err := h.toolInstaller.EnsureAvailable("git"); err != nil {
|
||||
return fmt.Errorf("git 不可用(安装失败): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("git", "clone", "--depth", "1", "-b", branch, url, workspace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
// 流式输出到日志
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||
cloneCmd := fmt.Sprintf(
|
||||
"git clone --depth 1 -b %s %s %s",
|
||||
shellQuotePath(branch), shellQuotePath(url), shellQuotePath(execCtx.Workspace),
|
||||
)
|
||||
output, err := h.execOnCtx(execCtx, "", cloneCmd, 15*time.Minute)
|
||||
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "代码检出", line)
|
||||
}
|
||||
@@ -240,19 +290,19 @@ func (h *PipelineHandler) runCheckout(url, branch, workspace string, logFn func(
|
||||
if err != nil {
|
||||
return fmt.Errorf("git clone 失败: %v", err)
|
||||
}
|
||||
hashCmd := exec.Command("git", "-C", workspace, "rev-parse", "--short", "HEAD")
|
||||
hashOut, _ := hashCmd.Output()
|
||||
logFn("success", "代码检出", fmt.Sprintf("已拉取 commit: %s", strings.TrimSpace(string(hashOut))))
|
||||
hashOut, _ := h.execOnCtx(execCtx, execCtx.Workspace, "git rev-parse --short HEAD", 30*time.Second)
|
||||
logFn("success", "代码检出", fmt.Sprintf("已拉取 commit: %s", strings.TrimSpace(hashOut)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// runBuild 构建(自动检测 Maven / npm / Go / Makefile)
|
||||
func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(string, string, string)) error {
|
||||
// runBuild 构建(自动检测 Maven / npm / Go / Makefile,支持远程节点) // Logan.Li
|
||||
func (h *PipelineHandler) runBuild(execCtx *PipelineExecCtx, dockerfile string, logFn func(string, string, string)) error {
|
||||
workspace := execCtx.Workspace
|
||||
buildDir := workspace
|
||||
if dockerfile != "" {
|
||||
if dir := dotdot(dockerfile); dir != "." && dir != "" {
|
||||
testDir := workspace + "/" + dir
|
||||
if _, err := os.Stat(testDir); err == nil {
|
||||
if h.pathExists(execCtx, testDir) {
|
||||
buildDir = testDir
|
||||
logFn("info", "构建", ">>> 切换到构建目录: "+dir)
|
||||
}
|
||||
@@ -260,30 +310,48 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri
|
||||
}
|
||||
|
||||
// 如果根目录没找到构建文件,扫描一级子目录
|
||||
if !hasBuildFile(buildDir) {
|
||||
if !h.hasBuildFileCtx(execCtx, buildDir) {
|
||||
type subMatch struct {
|
||||
dir string
|
||||
kind string // "maven", "npm", "go", "make"
|
||||
}
|
||||
var matches []subMatch
|
||||
entries, err := os.ReadDir(buildDir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || e.Name() == ".git" {
|
||||
if execCtx.Remote {
|
||||
out, _ := h.execOnCtx(execCtx, buildDir, `for d in */; do
|
||||
[ "$d" = ".git/" ] && continue
|
||||
[ -f "${d}pom.xml" ] && echo "maven:$d"
|
||||
[ -f "${d}package.json" ] && echo "npm:$d"
|
||||
[ -f "${d}go.mod" ] && echo "go:$d"
|
||||
[ -f "${d}Makefile" ] && echo "make:$d"
|
||||
done`, 60*time.Second)
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if line == "" || !strings.Contains(line, ":") {
|
||||
continue
|
||||
}
|
||||
sub := buildDir + "/" + e.Name()
|
||||
if _, err := os.Stat(sub + "/pom.xml"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "maven"})
|
||||
}
|
||||
if _, err := os.Stat(sub + "/package.json"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "npm"})
|
||||
}
|
||||
if _, err := os.Stat(sub + "/go.mod"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "go"})
|
||||
}
|
||||
if _, err := os.Stat(sub + "/Makefile"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "make"})
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
sub := strings.TrimSuffix(buildDir+"/"+strings.TrimSuffix(parts[1], "/"), "/")
|
||||
matches = append(matches, subMatch{sub, parts[0]})
|
||||
}
|
||||
} else {
|
||||
entries, err := os.ReadDir(buildDir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || e.Name() == ".git" {
|
||||
continue
|
||||
}
|
||||
sub := buildDir + "/" + e.Name()
|
||||
if _, err := os.Stat(sub + "/pom.xml"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "maven"})
|
||||
}
|
||||
if _, err := os.Stat(sub + "/package.json"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "npm"})
|
||||
}
|
||||
if _, err := os.Stat(sub + "/go.mod"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "go"})
|
||||
}
|
||||
if _, err := os.Stat(sub + "/Makefile"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "make"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -304,13 +372,19 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(buildDir + "/pom.xml"); err == nil {
|
||||
if h.pathExists(execCtx, buildDir+"/pom.xml") {
|
||||
if !execCtx.Remote && h.toolInstaller != nil {
|
||||
if err := h.toolInstaller.EnsureAvailable("jdk"); err != nil {
|
||||
return fmt.Errorf("JDK 不可用(安装失败): %v", err)
|
||||
}
|
||||
if err := h.toolInstaller.EnsureAvailable("maven"); err != nil {
|
||||
return fmt.Errorf("Maven 不可用(安装失败): %v", err)
|
||||
}
|
||||
}
|
||||
logFn("info", "构建", ">>> Maven 构建 (mvn clean package -DskipTests)")
|
||||
logFn("info", "构建", "🔨 正在下载依赖并编译...")
|
||||
cmd := exec.Command("mvn", "clean", "package", "-DskipTests")
|
||||
cmd.Dir = buildDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
out, err := h.execOnCtx(execCtx, buildDir, "mvn clean package -DskipTests", 30*time.Minute)
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "构建", line)
|
||||
}
|
||||
@@ -322,13 +396,16 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(buildDir + "/package.json"); err == nil {
|
||||
if h.pathExists(execCtx, buildDir+"/package.json") {
|
||||
if !execCtx.Remote && h.toolInstaller != nil {
|
||||
if err := h.toolInstaller.EnsureAvailable("nodejs"); err != nil {
|
||||
return fmt.Errorf("Node.js 不可用(安装失败): %v", err)
|
||||
}
|
||||
}
|
||||
logFn("info", "构建", ">>> npm 构建 (npm install && npm run build)")
|
||||
logFn("info", "构建", "📦 正在安装依赖...")
|
||||
installCmd := exec.Command("sh", "-c", "npm install --registry=https://registry.npmmirror.com --legacy-peer-deps 2>&1")
|
||||
installCmd.Dir = buildDir
|
||||
out, err := installCmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
out, err := h.execOnCtx(execCtx, buildDir, "npm install --registry=https://registry.npmmirror.com --legacy-peer-deps 2>&1", 30*time.Minute)
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "构建", line)
|
||||
}
|
||||
@@ -337,10 +414,8 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri
|
||||
return fmt.Errorf("npm install 失败: %v", err)
|
||||
}
|
||||
logFn("info", "构建", "🏗️ 正在打包构建...")
|
||||
buildCmd := exec.Command("sh", "-c", "npm run build 2>&1")
|
||||
buildCmd.Dir = buildDir
|
||||
out, err = buildCmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
out, err = h.execOnCtx(execCtx, buildDir, "npm run build 2>&1", 30*time.Minute)
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "构建", line)
|
||||
}
|
||||
@@ -352,12 +427,16 @@ func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(stri
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(buildDir + "/go.mod"); err == nil {
|
||||
if h.pathExists(execCtx, buildDir+"/go.mod") {
|
||||
if !execCtx.Remote && h.toolInstaller != nil {
|
||||
if err := h.toolInstaller.EnsureAvailable("golang"); err != nil {
|
||||
return fmt.Errorf("Go 不可用(安装失败): %v", err)
|
||||
}
|
||||
}
|
||||
logFn("info", "构建", ">>> Go 构建 (go build ./...)")
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = buildDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
goEnv := "export GOROOT=/usr/local/go PATH=/usr/local/go/bin:$PATH GOPROXY=https://goproxy.cn,direct GOTOOLCHAIN=local && "
|
||||
out, err := h.execOnCtx(execCtx, buildDir, goEnv+"go build ./...", 30*time.Minute)
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "构建", line)
|
||||
}
|
||||
@@ -383,14 +462,12 @@ func hasBuildFile(dir string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// runTest 执行测试
|
||||
func (h *PipelineHandler) runTest(workspace string, logFn func(string, string, string)) error {
|
||||
if _, err := os.Stat(workspace + "/pom.xml"); err == nil {
|
||||
// runTest 执行测试 // Logan.Li
|
||||
func (h *PipelineHandler) runTest(execCtx *PipelineExecCtx, logFn func(string, string, string)) error {
|
||||
if h.pathExists(execCtx, execCtx.Workspace+"/pom.xml") {
|
||||
logFn("info", "测试", ">>> Maven 测试 (mvn test)")
|
||||
cmd := exec.Command("mvn", "test")
|
||||
cmd.Dir = workspace
|
||||
out, _ := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
out, _ := h.execOnCtx(execCtx, execCtx.Workspace, "mvn test", 30*time.Minute)
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "测试", line)
|
||||
}
|
||||
@@ -399,31 +476,15 @@ func (h *PipelineHandler) runTest(workspace string, logFn func(string, string, s
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDockerBuild Docker 构建 & 推送
|
||||
func (h *PipelineHandler) runDockerBuild(workspace string, pipeline model.Pipeline, tag string, logFn func(string, string, string)) error {
|
||||
// runDockerBuild Docker 构建 & 推送(支持远程节点,需节点已安装 Docker) // Logan.Li
|
||||
func (h *PipelineHandler) runDockerBuild(execCtx *PipelineExecCtx, pipeline model.Pipeline, tag string, logFn func(string, string, string)) error {
|
||||
workspace := execCtx.Workspace
|
||||
dockerfile := pipeline.Dockerfile
|
||||
if dockerfile == "" {
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
|
||||
var registryURL, imageName string
|
||||
if pipeline.RegistryID != nil {
|
||||
var reg model.PipelineRegistry
|
||||
if err := h.db.First(®, *pipeline.RegistryID).Error; err == nil {
|
||||
registryURL = reg.RegistryURL
|
||||
if reg.Namespace != "" {
|
||||
imageName = reg.Namespace + "/" + pipeline.Code
|
||||
}
|
||||
}
|
||||
}
|
||||
if imageName == "" {
|
||||
imageName = pipeline.Code
|
||||
}
|
||||
if registryURL == "" {
|
||||
registryURL = "registry.example.com"
|
||||
}
|
||||
|
||||
fullImage := fmt.Sprintf("%s/%s:%s", registryURL, imageName, tag)
|
||||
fullImage := h.resolvePipelineImage(pipeline, tag)
|
||||
logFn("info", "镜像打包", fmt.Sprintf("镜像地址: %s", fullImage))
|
||||
|
||||
buildDir := workspace
|
||||
@@ -437,17 +498,15 @@ func (h *PipelineHandler) runDockerBuild(workspace string, pipeline model.Pipeli
|
||||
|
||||
logFn("info", "镜像打包", fmt.Sprintf(">>> Docker 构建 (dockerfile: %s)", dfPath))
|
||||
|
||||
// U4: 确保 Docker CLI 可用(slim 镜像模式下 docker-cli 为懒加载)// Logan.Li
|
||||
if h.toolInstaller != nil {
|
||||
if !execCtx.Remote && h.toolInstaller != nil {
|
||||
if err := h.toolInstaller.EnsureAvailable("docker-cli"); err != nil {
|
||||
return fmt.Errorf("docker 不可用(安装失败): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("docker", "build", "-t", fullImage, "-f", dfPath, ".")
|
||||
cmd.Dir = buildDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
buildCmd := fmt.Sprintf("docker build -t %s -f %s .", shellQuotePath(fullImage), shellQuotePath(dfPath))
|
||||
out, err := h.execOnCtx(execCtx, buildDir, buildCmd, 60*time.Minute)
|
||||
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "镜像打包", line)
|
||||
}
|
||||
@@ -458,9 +517,8 @@ func (h *PipelineHandler) runDockerBuild(workspace string, pipeline model.Pipeli
|
||||
logFn("success", "镜像打包", "Docker 构建成功")
|
||||
|
||||
logFn("info", "镜像打包", ">>> 推送镜像: "+fullImage)
|
||||
pushCmd := exec.Command("docker", "push", fullImage)
|
||||
pushOut, err := pushCmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(pushOut)), "\n") {
|
||||
pushOut, err := h.execOnCtx(execCtx, buildDir, "docker push "+shellQuotePath(fullImage), 60*time.Minute)
|
||||
for _, line := range strings.Split(strings.TrimSpace(pushOut), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "镜像打包", line)
|
||||
}
|
||||
@@ -472,8 +530,165 @@ func (h *PipelineHandler) runDockerBuild(workspace string, pipeline model.Pipeli
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDeploy 部署到 K8s / 服务器
|
||||
// runDeploy 部署:K8s 走 update-image CI;SSH 走 SCP + 远程命令 // Logan.Li
|
||||
func (h *PipelineHandler) runDeploy(workspace string, pipeline model.Pipeline, tag string, logFn func(string, string, string)) error {
|
||||
switch pipeline.DeployTarget {
|
||||
case "k8s":
|
||||
return h.deployToK8s(pipeline, tag, logFn)
|
||||
case "ssh":
|
||||
return h.deployToSSH(workspace, pipeline, logFn)
|
||||
default:
|
||||
return h.deployViaYaml(workspace, logFn)
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePipelineImage 解析流水线构建镜像全名(与镜像打包阶段一致) // Logan.Li
|
||||
func (h *PipelineHandler) resolvePipelineImage(pipeline model.Pipeline, tag string) string {
|
||||
var registryURL, imageName string
|
||||
if pipeline.RegistryID != nil {
|
||||
var reg model.PipelineRegistry
|
||||
if err := h.db.First(®, *pipeline.RegistryID).Error; err == nil {
|
||||
registryURL = reg.RegistryURL
|
||||
if reg.Namespace != "" {
|
||||
imageName = reg.Namespace + "/"
|
||||
}
|
||||
}
|
||||
}
|
||||
appName := pipeline.Code
|
||||
if pipeline.JobName != "" {
|
||||
appName = pipeline.JobName
|
||||
}
|
||||
if imageName == "" {
|
||||
imageName = appName
|
||||
} else {
|
||||
imageName += appName
|
||||
}
|
||||
if registryURL == "" {
|
||||
registryURL = "registry.example.com"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s:%s", registryURL, imageName, tag)
|
||||
}
|
||||
|
||||
// deployToK8s 调用 K8s API 更新 Deployment 镜像(等同 curl CI update-image) // Logan.Li
|
||||
func (h *PipelineHandler) deployToK8s(pipeline model.Pipeline, tag string, logFn func(string, string, string)) error {
|
||||
if pipeline.K8sDeployment == "" || pipeline.K8sNamespace == "" {
|
||||
return fmt.Errorf("未配置 K8s Deployment")
|
||||
}
|
||||
if pipeline.K8sClusterID == nil {
|
||||
return fmt.Errorf("未配置 K8s 集群")
|
||||
}
|
||||
var cluster model.KubernetesCluster
|
||||
if err := h.db.First(&cluster, *pipeline.K8sClusterID).Error; err != nil {
|
||||
return fmt.Errorf("集群不存在: %v", err)
|
||||
}
|
||||
client, err := buildK8sClient(&cluster)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fullImage := h.resolvePipelineImage(pipeline, tag)
|
||||
logFn("info", "部署", fmt.Sprintf(">>> CI 更新镜像 %s/%s", pipeline.K8sNamespace, pipeline.K8sDeployment))
|
||||
logFn("info", "部署", fmt.Sprintf("镜像: %s", fullImage))
|
||||
logFn("info", "部署", fmt.Sprintf("API: POST /kubernetes/%s/namespace/%s/deployments/%s/update-image",
|
||||
cluster.Code, pipeline.K8sNamespace, pipeline.K8sDeployment))
|
||||
|
||||
if err := client.UpdateDeploymentImage(pipeline.K8sNamespace, pipeline.K8sDeployment, pipeline.K8sContainerName, fullImage); err != nil {
|
||||
return fmt.Errorf("更新 Deployment 镜像失败: %v", err)
|
||||
}
|
||||
logFn("success", "部署", "Deployment 镜像已更新,滚动发布已触发")
|
||||
return nil
|
||||
}
|
||||
|
||||
// deployToSSH 通过 SCP 上传构建产物并在关联服务器上执行部署命令 // Logan.Li
|
||||
func (h *PipelineHandler) deployToSSH(workspace string, pipeline model.Pipeline, logFn func(string, string, string)) error {
|
||||
if pipeline.ServerIDs == "" {
|
||||
return fmt.Errorf("未关联部署主机")
|
||||
}
|
||||
var serverIDs []uint
|
||||
if err := json.Unmarshal([]byte(pipeline.ServerIDs), &serverIDs); err != nil || len(serverIDs) == 0 {
|
||||
return fmt.Errorf("server_ids 解析失败")
|
||||
}
|
||||
|
||||
var servers []model.Server
|
||||
if err := h.db.Where("id IN ?", serverIDs).Find(&servers).Error; err != nil {
|
||||
return fmt.Errorf("加载服务器失败: %v", err)
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
return fmt.Errorf("关联的服务器不存在")
|
||||
}
|
||||
|
||||
artifact := findDeployArtifact(workspace)
|
||||
if artifact == "" {
|
||||
return fmt.Errorf("工作区未找到可部署产物(jar/tar.gz/zip)")
|
||||
}
|
||||
|
||||
remoteDir := strings.TrimSpace(pipeline.DeployRemotePath)
|
||||
if remoteDir == "" {
|
||||
remoteDir = fmt.Sprintf("/opt/apps/%s", pipeline.Code)
|
||||
}
|
||||
remotePath := remoteDir + "/" + filepath.Base(artifact)
|
||||
|
||||
deployScript := strings.TrimSpace(pipeline.DeployScript)
|
||||
if deployScript == "" {
|
||||
deployScript = fmt.Sprintf(
|
||||
"mkdir -p %s && cd %s && (test -x ./restart.sh && ./restart.sh) || (systemctl restart %s 2>/dev/null) || echo '部署完成,请配置 deploy_script'",
|
||||
remoteDir, remoteDir, pipeline.Code,
|
||||
)
|
||||
}
|
||||
|
||||
configs := make([]remoteexec.SSHConfig, 0, len(servers))
|
||||
hostnames := make([]string, 0, len(servers))
|
||||
for _, srv := range servers {
|
||||
cfg, err := buildSSHConfig(h.db, srv)
|
||||
if err != nil {
|
||||
logFn("error", "部署", fmt.Sprintf("%s: %v", srv.Hostname, err))
|
||||
continue
|
||||
}
|
||||
configs = append(configs, cfg)
|
||||
hostnames = append(hostnames, srv.Hostname)
|
||||
}
|
||||
if len(configs) == 0 {
|
||||
return fmt.Errorf("无可用 SSH 配置,请检查服务器凭据")
|
||||
}
|
||||
|
||||
logFn("info", "部署", fmt.Sprintf(">>> SCP %s → %s(%s)", filepath.Base(artifact), remotePath, strings.Join(hostnames, ", ")))
|
||||
uploadResults := remoteexec.BatchUpload(configs, artifact, remotePath, 5)
|
||||
for _, r := range uploadResults {
|
||||
if r.Success {
|
||||
logFn("success", "部署", fmt.Sprintf("✓ %s 上传成功", r.Host))
|
||||
} else {
|
||||
logFn("error", "部署", fmt.Sprintf("✗ %s 上传失败: %s", r.Host, r.Error))
|
||||
}
|
||||
}
|
||||
for _, r := range uploadResults {
|
||||
if !r.Success {
|
||||
return fmt.Errorf("部分主机 SCP 上传失败")
|
||||
}
|
||||
}
|
||||
|
||||
logFn("info", "部署", ">>> 远程执行部署命令")
|
||||
execResults := remoteexec.BatchExec(configs, deployScript, 5*time.Minute, 5)
|
||||
for _, r := range execResults {
|
||||
for _, line := range strings.Split(r.Output, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
logFn("info", "部署", fmt.Sprintf("[%s] %s", r.Host, line))
|
||||
}
|
||||
}
|
||||
if !r.Success {
|
||||
errMsg := r.Error
|
||||
if errMsg == "" {
|
||||
errMsg = fmt.Sprintf("exit=%d", r.ExitCode)
|
||||
}
|
||||
logFn("error", "部署", fmt.Sprintf("✗ %s 部署失败: %s", r.Host, errMsg))
|
||||
return fmt.Errorf("主机 %s 部署失败", r.Host)
|
||||
}
|
||||
logFn("success", "部署", fmt.Sprintf("✓ %s 部署完成", r.Host))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// deployViaYaml 兜底:仓库内 K8s YAML kubectl apply // Logan.Li
|
||||
func (h *PipelineHandler) deployViaYaml(workspace string, logFn func(string, string, string)) error {
|
||||
deployYamls := findFiles(workspace, ".yaml", ".yml")
|
||||
var k8sFiles []string
|
||||
for _, f := range deployYamls {
|
||||
@@ -485,12 +700,10 @@ func (h *PipelineHandler) runDeploy(workspace string, pipeline model.Pipeline, t
|
||||
k8sFiles = append(k8sFiles, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(k8sFiles) == 0 {
|
||||
logFn("info", "部署", "未找到 K8s 部署文件,跳过部署")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, yamlFile := range k8sFiles {
|
||||
logFn("info", "部署", ">>> kubectl apply -f "+yamlFile)
|
||||
cmd := exec.Command("kubectl", "apply", "-f", yamlFile)
|
||||
@@ -501,14 +714,65 @@ func (h *PipelineHandler) runDeploy(workspace string, pipeline model.Pipeline, t
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logFn("error", "部署", fmt.Sprintf("kubectl apply 失败: %v", err))
|
||||
return fmt.Errorf("kubectl apply 失败")
|
||||
return fmt.Errorf("kubectl apply 失败: %v", err)
|
||||
}
|
||||
}
|
||||
logFn("success", "部署", "部署完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// findDeployArtifact 在工作区查找可 SCP 的构建产物 // Logan.Li
|
||||
func findDeployArtifact(workspace string) string {
|
||||
priorityDirs := []string{"target", "build/libs", "dist", "bin"}
|
||||
priorityExts := []string{".jar", ".tar.gz", ".tgz", ".zip", ".war"}
|
||||
|
||||
var bestPath string
|
||||
var bestTime time.Time
|
||||
|
||||
checkFile := func(path string) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
return
|
||||
}
|
||||
if info.ModTime().After(bestTime) {
|
||||
bestTime = info.ModTime()
|
||||
bestPath = path
|
||||
}
|
||||
}
|
||||
|
||||
for _, dir := range priorityDirs {
|
||||
fullDir := filepath.Join(workspace, dir)
|
||||
entries, err := os.ReadDir(fullDir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
for _, ext := range priorityExts {
|
||||
if strings.HasSuffix(name, ext) {
|
||||
checkFile(filepath.Join(fullDir, name))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestPath != "" {
|
||||
return bestPath
|
||||
}
|
||||
|
||||
// 根目录兜底扫描
|
||||
for _, ext := range priorityExts {
|
||||
for _, f := range findFiles(workspace, ext) {
|
||||
checkFile(f)
|
||||
}
|
||||
}
|
||||
return bestPath
|
||||
}
|
||||
|
||||
// runVerify 部署后验证
|
||||
func (h *PipelineHandler) runVerify(workspace string, logFn func(string, string, string)) error {
|
||||
logFn("info", "验证", ">>> 部署后健康检查")
|
||||
@@ -578,27 +842,51 @@ var (
|
||||
queueMu sync.Mutex
|
||||
)
|
||||
|
||||
// tryExecuteOrQueue 触发流水线时检查并发限制
|
||||
// 如果当前运行数 < maxExecutors → 立即执行
|
||||
// 否则 → 加入队列 (status=queued)
|
||||
// tryExecuteOrQueue 触发流水线时检查流水线并发 + 构建节点槽位 // Logan.Li
|
||||
func (h *PipelineHandler) tryExecuteOrQueue(pipeline model.Pipeline, run model.PipelineRun) {
|
||||
max := pipeline.MaxExecutors
|
||||
if max <= 0 { max = 2 }
|
||||
if max <= 0 {
|
||||
max = 2
|
||||
}
|
||||
|
||||
queueMu.Lock()
|
||||
running := pipelineRunningCount[pipeline.ID]
|
||||
if running < max {
|
||||
pipelineRunningCount[pipeline.ID] = running + 1
|
||||
if running >= max {
|
||||
pipelineQueues[pipeline.ID] = append(pipelineQueues[pipeline.ID], run.ID)
|
||||
queueMu.Unlock()
|
||||
// 立即执行
|
||||
h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("status", "running")
|
||||
go h.executePipeline(pipeline, run)
|
||||
h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("status", "queued")
|
||||
return
|
||||
}
|
||||
// 加入队列
|
||||
pipelineQueues[pipeline.ID] = append(pipelineQueues[pipeline.ID], run.ID)
|
||||
queueMu.Unlock()
|
||||
h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("status", "queued")
|
||||
|
||||
nodeID, canNode := h.tryAcquireBuildNode(pipeline)
|
||||
if !canNode {
|
||||
queueMu.Lock()
|
||||
pipelineQueues[pipeline.ID] = append(pipelineQueues[pipeline.ID], run.ID)
|
||||
queueMu.Unlock()
|
||||
h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Update("status", "queued")
|
||||
return
|
||||
}
|
||||
|
||||
queueMu.Lock()
|
||||
pipelineRunningCount[pipeline.ID] = running + 1
|
||||
queueMu.Unlock()
|
||||
|
||||
h.markRunStarting(run, nodeID)
|
||||
go h.executePipeline(pipeline, run, nodeID)
|
||||
}
|
||||
|
||||
// markRunStarting 标记运行开始并记录执行节点 // Logan.Li
|
||||
func (h *PipelineHandler) markRunStarting(run model.PipelineRun, nodeID uint) {
|
||||
updates := map[string]interface{}{"status": "running", "build_node_name": "Master"}
|
||||
if nodeID > 0 {
|
||||
updates["build_node_id"] = nodeID
|
||||
var node model.BuildNode
|
||||
if h.db.First(&node, nodeID).Error == nil {
|
||||
updates["build_node_name"] = node.Name
|
||||
}
|
||||
}
|
||||
h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
||||
}
|
||||
|
||||
// dequeueNext 流水线运行结束后,从队列中取下一个执行
|
||||
@@ -625,8 +913,19 @@ func (h *PipelineHandler) dequeueNext(pipelineID uint) {
|
||||
var run model.PipelineRun
|
||||
if err := h.db.First(&run, nextRunID).Error; err != nil { return }
|
||||
|
||||
h.db.Model(&run).Update("status", "running")
|
||||
go h.executePipeline(pipeline, run)
|
||||
nodeID, canNode := h.tryAcquireBuildNode(pipeline)
|
||||
if !canNode {
|
||||
queueMu.Lock()
|
||||
pipelineQueues[pipelineID] = append([]uint{nextRunID}, pipelineQueues[pipelineID]...)
|
||||
if pipelineRunningCount[pipelineID] > 0 {
|
||||
pipelineRunningCount[pipelineID]--
|
||||
}
|
||||
queueMu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
h.markRunStarting(run, nodeID)
|
||||
go h.executePipeline(pipeline, run, nodeID)
|
||||
}
|
||||
|
||||
// GetQueueStatus 查询流水线队列状态(供 API 使用)
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/remoteexec"
|
||||
)
|
||||
|
||||
// Master 内置节点 ID(0 表示在本机执行) // Logan.Li
|
||||
const masterNodeID uint = 0
|
||||
|
||||
var (
|
||||
nodeRunningCount = make(map[uint]int)
|
||||
masterMaxExecutors = 2
|
||||
nodeMu sync.Mutex
|
||||
)
|
||||
|
||||
// ensureBuildMasterConfig 确保 Master 配置行存在并加载到内存 // Logan.Li
|
||||
func (h *PipelineHandler) ensureBuildMasterConfig() model.BuildMasterConfig {
|
||||
var cfg model.BuildMasterConfig
|
||||
if err := h.db.First(&cfg).Error; err != nil {
|
||||
cfg = model.BuildMasterConfig{MaxExecutors: 2}
|
||||
h.db.Create(&cfg)
|
||||
}
|
||||
if cfg.MaxExecutors <= 0 {
|
||||
cfg.MaxExecutors = 2
|
||||
}
|
||||
nodeMu.Lock()
|
||||
masterMaxExecutors = cfg.MaxExecutors
|
||||
nodeMu.Unlock()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// setMasterMaxExecutors 更新 Master 并发上限(内存 + DB) // Logan.Li
|
||||
func (h *PipelineHandler) setMasterMaxExecutors(n int) model.BuildMasterConfig {
|
||||
if n <= 0 {
|
||||
n = 1
|
||||
}
|
||||
cfg := h.ensureBuildMasterConfig()
|
||||
cfg.MaxExecutors = n
|
||||
h.db.Model(&cfg).Update("max_executors", n)
|
||||
nodeMu.Lock()
|
||||
masterMaxExecutors = n
|
||||
nodeMu.Unlock()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// PipelineExecCtx 流水线执行上下文(Master 或远程 SSH 节点) // Logan.Li
|
||||
type PipelineExecCtx struct {
|
||||
Remote bool
|
||||
SSHCfg *remoteexec.SSHConfig
|
||||
Workspace string
|
||||
NodeID uint
|
||||
NodeName string
|
||||
}
|
||||
|
||||
// tryAcquireBuildNode 尝试占用构建节点执行槽位 // Logan.Li
|
||||
func (h *PipelineHandler) tryAcquireBuildNode(pipeline model.Pipeline) (uint, bool) {
|
||||
nodeMu.Lock()
|
||||
defer nodeMu.Unlock()
|
||||
|
||||
// 指定节点
|
||||
if pipeline.BuildNodeID != nil && *pipeline.BuildNodeID > 0 {
|
||||
id := *pipeline.BuildNodeID
|
||||
var node model.BuildNode
|
||||
if h.db.First(&node, id).Error != nil || !node.Enabled {
|
||||
return h.acquireMasterLocked()
|
||||
}
|
||||
max := node.MaxExecutors
|
||||
if max <= 0 {
|
||||
max = 1
|
||||
}
|
||||
if nodeRunningCount[id] < max {
|
||||
nodeRunningCount[id]++
|
||||
return id, true
|
||||
}
|
||||
return id, false
|
||||
}
|
||||
|
||||
// 自动调度:Master 优先,其次负载最低的远程节点
|
||||
if id, ok := h.acquireMasterLocked(); ok {
|
||||
return id, true
|
||||
}
|
||||
|
||||
var nodes []model.BuildNode
|
||||
h.db.Where("enabled = ?", true).Order("id ASC").Find(&nodes)
|
||||
bestID := uint(0)
|
||||
bestScore := 2.0
|
||||
for _, n := range nodes {
|
||||
max := n.MaxExecutors
|
||||
if max <= 0 {
|
||||
max = 1
|
||||
}
|
||||
score := float64(nodeRunningCount[n.ID]) / float64(max)
|
||||
if score < 1.0 && score <= bestScore {
|
||||
bestScore = score
|
||||
bestID = n.ID
|
||||
}
|
||||
}
|
||||
if bestID > 0 {
|
||||
nodeRunningCount[bestID]++
|
||||
return bestID, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (h *PipelineHandler) acquireMasterLocked() (uint, bool) {
|
||||
if nodeRunningCount[masterNodeID] < masterMaxExecutors {
|
||||
nodeRunningCount[masterNodeID]++
|
||||
return masterNodeID, true
|
||||
}
|
||||
return masterNodeID, false
|
||||
}
|
||||
|
||||
// releaseBuildNode 释放构建节点执行槽位 // Logan.Li
|
||||
func (h *PipelineHandler) releaseBuildNode(nodeID uint) {
|
||||
nodeMu.Lock()
|
||||
defer nodeMu.Unlock()
|
||||
if nodeRunningCount[nodeID] > 0 {
|
||||
nodeRunningCount[nodeID]--
|
||||
}
|
||||
}
|
||||
|
||||
// buildExecCtx 根据节点 ID 构建执行上下文 // Logan.Li
|
||||
func (h *PipelineHandler) buildExecCtx(nodeID uint, workspace string) (*PipelineExecCtx, error) {
|
||||
ctx := &PipelineExecCtx{
|
||||
Workspace: workspace,
|
||||
NodeID: nodeID,
|
||||
NodeName: "Master",
|
||||
}
|
||||
if nodeID == masterNodeID {
|
||||
return ctx, nil
|
||||
}
|
||||
var node model.BuildNode
|
||||
if err := h.db.First(&node, nodeID).Error; err != nil {
|
||||
return nil, fmt.Errorf("构建节点不存在")
|
||||
}
|
||||
var srv model.Server
|
||||
if err := h.db.First(&srv, node.ServerID).Error; err != nil {
|
||||
return nil, fmt.Errorf("节点关联服务器不存在")
|
||||
}
|
||||
cfg, err := buildSSHConfig(h.db, srv)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx.Remote = true
|
||||
ctx.SSHCfg = &cfg
|
||||
ctx.NodeName = node.Name
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
// remoteWorkspacePath 计算远程工作区路径 // Logan.Li
|
||||
func remoteWorkspacePath(node model.BuildNode, repoName, code string) string {
|
||||
base := strings.TrimRight(node.WorkspacePath, "/")
|
||||
if base == "" {
|
||||
base = "/data/aiops-workspace"
|
||||
}
|
||||
return fmt.Sprintf("%s/%s-%s", base, repoName, code)
|
||||
}
|
||||
|
||||
// execOnCtx 在 Master 或远程节点执行 shell 命令 // Logan.Li
|
||||
func (h *PipelineHandler) execOnCtx(ctx *PipelineExecCtx, workDir, command string, timeout time.Duration) (string, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Minute
|
||||
}
|
||||
if !ctx.Remote {
|
||||
cmd := exec.Command("bash", "-c", command)
|
||||
if workDir != "" {
|
||||
cmd.Dir = workDir
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
fullCmd := command
|
||||
if workDir != "" {
|
||||
fullCmd = fmt.Sprintf("cd %s && %s", shellQuotePath(workDir), command)
|
||||
}
|
||||
result, err := remoteexec.Exec(*ctx.SSHCfg, fullCmd, timeout)
|
||||
output := ""
|
||||
if result != nil {
|
||||
output = result.Output
|
||||
}
|
||||
if err != nil || (result != nil && !result.Success) {
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
if result.Error != "" {
|
||||
return output, fmt.Errorf("%s", result.Error)
|
||||
}
|
||||
return output, fmt.Errorf("exit=%d", result.ExitCode)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// prepareWorkspace 初始化工作区(本地或远程) // Logan.Li
|
||||
func (h *PipelineHandler) prepareWorkspace(ctx *PipelineExecCtx, logFn func(string, string, string)) error {
|
||||
if !ctx.Remote {
|
||||
os.RemoveAll(ctx.Workspace)
|
||||
return os.MkdirAll(ctx.Workspace, 0755)
|
||||
}
|
||||
parent := filepath.Dir(ctx.Workspace)
|
||||
_, err := h.execOnCtx(ctx, "", fmt.Sprintf("rm -rf %s && mkdir -p %s", shellQuotePath(ctx.Workspace), shellQuotePath(parent)), 5*time.Minute)
|
||||
return err
|
||||
}
|
||||
|
||||
// syncRemoteWorkspaceToLocal 将远程工作区同步到 Master(部署阶段需要本地产物) // Logan.Li
|
||||
func (h *PipelineHandler) syncRemoteWorkspaceToLocal(ctx *PipelineExecCtx, localWS string, logFn func(string, string, string)) error {
|
||||
if !ctx.Remote || ctx.SSHCfg == nil {
|
||||
return nil
|
||||
}
|
||||
logFn("info", "同步", fmt.Sprintf(">>> 从节点 %s 回拉工作区", ctx.NodeName))
|
||||
tmpTar := localWS + ".tar.gz"
|
||||
os.RemoveAll(localWS)
|
||||
os.MkdirAll(localWS, 0755)
|
||||
|
||||
if err := remoteexec.StreamRemoteTar(*ctx.SSHCfg, ctx.Workspace, tmpTar, 30*time.Minute); err != nil {
|
||||
return fmt.Errorf("远程打包下载失败: %v", err)
|
||||
}
|
||||
defer os.Remove(tmpTar)
|
||||
|
||||
extract := exec.Command("tar", "xzf", tmpTar, "-C", localWS)
|
||||
if out, err := extract.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("解压工作区失败: %v, %s", err, string(out))
|
||||
}
|
||||
logFn("success", "同步", "工作区已同步到 Master")
|
||||
return nil
|
||||
}
|
||||
|
||||
// statOnCtx 检查远程/本地文件是否存在 // Logan.Li
|
||||
func (h *PipelineHandler) statOnCtx(ctx *PipelineExecCtx, path string) bool {
|
||||
if !ctx.Remote {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
out, err := h.execOnCtx(ctx, "", fmt.Sprintf("test -e %s && echo yes", shellQuotePath(path)), 30*time.Second)
|
||||
return err == nil && strings.Contains(out, "yes")
|
||||
}
|
||||
|
||||
func shellQuotePath(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
// pathExists 检查 Master/远程路径是否存在 // Logan.Li
|
||||
func (h *PipelineHandler) pathExists(ctx *PipelineExecCtx, path string) bool {
|
||||
return h.statOnCtx(ctx, path)
|
||||
}
|
||||
|
||||
// hasBuildFileCtx 检查目录是否包含可识别的构建文件 // Logan.Li
|
||||
func (h *PipelineHandler) hasBuildFileCtx(ctx *PipelineExecCtx, dir string) bool {
|
||||
for _, f := range []string{"pom.xml", "package.json", "go.mod", "Makefile"} {
|
||||
if h.pathExists(ctx, dir+"/"+f) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -71,9 +71,7 @@ func (h *SiteHandler) CreateTask(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "名称和地址不能为空"})
|
||||
return
|
||||
}
|
||||
if task.Frequency < 10 {
|
||||
task.Frequency = 60
|
||||
}
|
||||
task.Frequency = siteprobe.NormalizeProbeFrequency(string(task.TaskType), task.Frequency)
|
||||
if task.Timeout <= 0 {
|
||||
task.Timeout = 10
|
||||
}
|
||||
@@ -118,7 +116,11 @@ func (h *SiteHandler) UpdateTask(c *gin.Context) {
|
||||
updates["task_type"] = t
|
||||
}
|
||||
if v, ok := raw["frequency_sec"].(float64); ok {
|
||||
updates["frequency"] = int(v)
|
||||
taskType := string(task.TaskType)
|
||||
if tv, ok := updates["task_type"].(string); ok {
|
||||
taskType = tv
|
||||
}
|
||||
updates["frequency"] = siteprobe.NormalizeProbeFrequency(taskType, int(v))
|
||||
}
|
||||
if v, ok := raw["timeout_sec"].(float64); ok {
|
||||
updates["timeout"] = int(v)
|
||||
|
||||
@@ -70,6 +70,12 @@ func enrichSiteTaskViewsAlert(db *gorm.DB, views []SiteTaskView) {
|
||||
if len(views) == 0 {
|
||||
return
|
||||
}
|
||||
// 列表加载时清理已恢复任务的陈旧 active 告警(与 down_count 对齐)// Logan.Li
|
||||
for _, v := range views {
|
||||
if v.LastStatus == "up" {
|
||||
siteprobe.ResolveRecoveredProbeAlerts(db, v.ID, "success")
|
||||
}
|
||||
}
|
||||
ids := make([]uint, 0, len(views))
|
||||
for _, v := range views {
|
||||
ids = append(ids, v.ID)
|
||||
@@ -87,9 +93,15 @@ func enrichSiteTaskViewsAlert(db *gorm.DB, views []SiteTaskView) {
|
||||
alerting[r.TaskID] = true
|
||||
}
|
||||
for i := range views {
|
||||
if alerting[views[i].ID] {
|
||||
views[i].AlertStatus = "alerting"
|
||||
if !alerting[views[i].ID] {
|
||||
continue
|
||||
}
|
||||
// 最近一次拨测成功则显示正常(与 down_count 一致;SSL 证书预警走行内徽章)// Logan.Li
|
||||
if views[i].LastStatus == "up" {
|
||||
views[i].AlertStatus = "normal"
|
||||
continue
|
||||
}
|
||||
views[i].AlertStatus = "alerting"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +207,7 @@ func parseCreateTaskRequest(raw map[string]interface{}) model.SiteTask {
|
||||
TaskType: model.SiteTaskType(taskType),
|
||||
Method: model.SiteTaskMethodGET,
|
||||
Timeout: getInt(10, "timeout_sec", "timeout"),
|
||||
Frequency: getInt(60, "frequency_sec", "frequency"),
|
||||
Frequency: siteprobe.NormalizeProbeFrequency(taskType, getInt(0, "frequency_sec", "frequency")),
|
||||
ExpectedStatus: getInt(200, "expected_status_code", "expected_status"),
|
||||
FollowRedirect: true,
|
||||
VerifySSL: true,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
"ezops/pkg/toolinstaller"
|
||||
@@ -25,17 +27,69 @@ func NewToolConfigHandler(db *gorm.DB, ti *toolinstaller.ToolInstaller) *ToolCon
|
||||
return &ToolConfigHandler{db: db, installer: ti}
|
||||
}
|
||||
|
||||
// toolAuditCtx 工具安装审计上下文快照 // Logan.Li
|
||||
type toolAuditCtx struct {
|
||||
userID uint
|
||||
username string
|
||||
ip string
|
||||
userAgent string
|
||||
}
|
||||
|
||||
func newToolAuditCtx(c *gin.Context) toolAuditCtx {
|
||||
uid, _ := c.Get("userID")
|
||||
uname, _ := c.Get("username")
|
||||
id, _ := uid.(uint)
|
||||
name, _ := uname.(string)
|
||||
return toolAuditCtx{
|
||||
userID: id, username: name,
|
||||
ip: c.ClientIP(), userAgent: c.Request.UserAgent(),
|
||||
}
|
||||
}
|
||||
|
||||
func truncateToolAuditText(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
return s[:n] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// recordToolAudit 记录工具安装/检测审计(含安装脚本输出与验证结果)// Logan.Li
|
||||
func (h *ToolConfigHandler) recordToolAudit(ac toolAuditCtx, action, toolName, version string, status int, reqDetail, respDetail, errMsg string) {
|
||||
uid := ac.userID
|
||||
h.db.Create(&model.AuditLog{
|
||||
UserID: &uid,
|
||||
Username: ac.username,
|
||||
IP: ac.ip,
|
||||
UserAgent: ac.userAgent,
|
||||
Module: "deploy_tool",
|
||||
Action: action,
|
||||
Resource: toolName,
|
||||
ResourceID: version,
|
||||
Request: truncateToolAuditText(reqDetail, 2048),
|
||||
Response: truncateToolAuditText(respDetail, 8192),
|
||||
Status: status,
|
||||
Error: truncateToolAuditText(errMsg, 512),
|
||||
})
|
||||
}
|
||||
|
||||
// ListTools returns all configured tools
|
||||
func (h *ToolConfigHandler) ListTools(c *gin.Context) {
|
||||
var tools []model.DeployTool
|
||||
h.db.Order("sort_order ASC").Find(&tools)
|
||||
// 列表加载时同步失败/安装中状态(磁盘已存在则自动纠正)// Logan.Li
|
||||
for i := range tools {
|
||||
if tools[i].Status == model.ToolFailed || tools[i].Status == model.ToolInstalling {
|
||||
tools[i] = h.installer.SyncToolStatus(tools[i].ID)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: model.ListResponse{List: tools, Total: int64(len(tools))}})
|
||||
}
|
||||
|
||||
// GetTool returns a single tool
|
||||
func (h *ToolConfigHandler) GetTool(c *gin.Context) {
|
||||
var tool model.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
|
||||
if err := h.db.First(&tool, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
@@ -45,7 +99,7 @@ func (h *ToolConfigHandler) GetTool(c *gin.Context) {
|
||||
// UpdateTool updates a tool's configuration
|
||||
func (h *ToolConfigHandler) UpdateTool(c *gin.Context) {
|
||||
var tool model.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
|
||||
if err := h.db.First(&tool, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
@@ -92,7 +146,7 @@ func (h *ToolConfigHandler) UpdateTool(c *gin.Context) {
|
||||
// InstallTool triggers lazy installation with optional version override
|
||||
func (h *ToolConfigHandler) InstallTool(c *gin.Context) {
|
||||
var tool model.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
|
||||
if err := h.db.First(&tool, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
@@ -107,7 +161,6 @@ func (h *ToolConfigHandler) InstallTool(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Support version override via query param or JSON body
|
||||
version := tool.DefaultVersion
|
||||
if v := c.Query("version"); v != "" {
|
||||
version = v
|
||||
@@ -115,7 +168,6 @@ func (h *ToolConfigHandler) InstallTool(c *gin.Context) {
|
||||
if v := c.PostForm("version"); v != "" {
|
||||
version = v
|
||||
}
|
||||
// Also support JSON body
|
||||
var req struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
@@ -123,62 +175,125 @@ func (h *ToolConfigHandler) InstallTool(c *gin.Context) {
|
||||
version = req.Version
|
||||
}
|
||||
|
||||
// Store the requested version for the installer
|
||||
if version != "" && version != tool.DefaultVersion {
|
||||
h.db.Model(&tool).Update("default_version", version)
|
||||
tool.DefaultVersion = version
|
||||
}
|
||||
|
||||
// Mark as installing immediately
|
||||
h.db.Model(&tool).Update("status", model.ToolInstalling)
|
||||
h.db.Model(&tool).Updates(map[string]interface{}{
|
||||
"status": model.ToolInstalling, "error_msg": "",
|
||||
})
|
||||
|
||||
// Background installation — actually call the installer
|
||||
ac := newToolAuditCtx(c)
|
||||
toolName := tool.Name
|
||||
toolDisplay := tool.DisplayName
|
||||
go func() {
|
||||
log.Printf("[ToolConfig] Background install triggered for %s (version: %s)", tool.Name, version)
|
||||
if err := h.installer.ForceInstall(tool.Name, version); err != nil {
|
||||
log.Printf("[ToolConfig] ERROR installing %s: %v", tool.Name, err)
|
||||
log.Printf("[ToolConfig] Background install triggered for %s (version: %s)", toolName, version)
|
||||
reqDetail := fmt.Sprintf("tool=%s version=%s", toolName, version)
|
||||
res := h.installer.ForceInstall(toolName, version)
|
||||
var latest model.DeployTool
|
||||
if h.db.Where("name = ?", toolName).First(&latest).Error == nil {
|
||||
latest = h.installer.SyncToolStatus(latest.ID)
|
||||
}
|
||||
respDetail := res.Output
|
||||
if res.VerifyOut != "" {
|
||||
respDetail += "\n--- verify ---\n" + res.VerifyOut
|
||||
}
|
||||
if res.Installed && res.Version != "" {
|
||||
respDetail += "\n--- version ---\n" + res.Version
|
||||
}
|
||||
if res.Err != nil {
|
||||
log.Printf("[ToolConfig] ERROR installing %s: %v", toolName, res.Err)
|
||||
h.recordToolAudit(ac, "install", toolDisplay, version, 0, reqDetail, respDetail, res.Err.Error())
|
||||
return
|
||||
}
|
||||
success := res.Installed || latest.Status == model.ToolInstalled
|
||||
log.Printf("[ToolConfig] %s installed successfully (status=%s installed=%v)", toolName, latest.Status, success)
|
||||
if success {
|
||||
h.recordToolAudit(ac, "install", toolDisplay, version, 1, reqDetail, respDetail, "")
|
||||
} else {
|
||||
log.Printf("[ToolConfig] %s installed successfully", tool.Name)
|
||||
h.recordToolAudit(ac, "install", toolDisplay, version, 0, reqDetail, respDetail, latest.ErrorMsg)
|
||||
}
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "安装已触发", Data: gin.H{"version": version}})
|
||||
}
|
||||
|
||||
// CheckToolStatus re-checks a tool's installation status
|
||||
func (h *ToolConfigHandler) CheckToolStatus(c *gin.Context) {
|
||||
// UninstallTool 卸载工具(清理磁盘资源,可重新选择版本安装)// Logan.Li
|
||||
func (h *ToolConfigHandler) UninstallTool(c *gin.Context) {
|
||||
var tool model.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
|
||||
if err := h.db.First(&tool, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
available := false
|
||||
version := ""
|
||||
if tool.CheckCommand != "" {
|
||||
cmd := exec.Command("sh", "-c", tool.CheckCommand)
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
available = true
|
||||
version = string(out)
|
||||
if len(version) > 128 {
|
||||
version = version[:128]
|
||||
}
|
||||
}
|
||||
if tool.InstallMethod != model.ToolLazy {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "仅懒加载工具可手动卸载"})
|
||||
return
|
||||
}
|
||||
|
||||
status := model.ToolNotInstalled
|
||||
if tool.Status != model.ToolInstalled && tool.Status != model.ToolFailed {
|
||||
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "当前状态不可卸载,请先检测或等待安装结束"})
|
||||
return
|
||||
}
|
||||
|
||||
ac := newToolAuditCtx(c)
|
||||
toolName := tool.Name
|
||||
toolDisplay := tool.DisplayName
|
||||
installedVer := tool.InstalledVer
|
||||
reqDetail := fmt.Sprintf("tool=%s installed_ver=%s", toolName, installedVer)
|
||||
|
||||
res := h.installer.Uninstall(toolName)
|
||||
respDetail := res.Output
|
||||
if res.Err != nil {
|
||||
h.recordToolAudit(ac, "uninstall", toolDisplay, installedVer, 0, reqDetail, respDetail, res.Err.Error())
|
||||
c.JSON(http.StatusOK, model.Response{Code: 500, Message: "卸载失败: " + res.Err.Error(), Data: gin.H{"output": respDetail}})
|
||||
return
|
||||
}
|
||||
|
||||
h.recordToolAudit(ac, "uninstall", toolDisplay, installedVer, 1, reqDetail, respDetail, "")
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "卸载完成", Data: gin.H{"output": respDetail}})
|
||||
}
|
||||
|
||||
// CheckToolStatus re-checks a tool's installation status
|
||||
func (h *ToolConfigHandler) CheckToolStatus(c *gin.Context) {
|
||||
var tool model.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 优先磁盘验证,纠正「审计成功但 DB 仍失败/安装中」// Logan.Li
|
||||
available, version := h.installer.VerifyTool(tool)
|
||||
now := time.Now()
|
||||
|
||||
if available {
|
||||
status = model.ToolInstalled
|
||||
h.db.Model(&tool).Updates(map[string]interface{}{
|
||||
"status": model.ToolInstalled, "installed_ver": version,
|
||||
"error_msg": "", "last_check": &now,
|
||||
})
|
||||
tool.Status = model.ToolInstalled
|
||||
tool.InstalledVer = version
|
||||
tool.ErrorMsg = ""
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||
"available": true, "version": version, "tool": tool,
|
||||
}})
|
||||
return
|
||||
}
|
||||
|
||||
// 安装中超时
|
||||
if tool.Status == model.ToolInstalling && tool.LastCheck != nil && time.Since(*tool.LastCheck) > 10*time.Minute {
|
||||
msg := "安装超时,请重试"
|
||||
h.db.Model(&tool).Updates(map[string]interface{}{
|
||||
"status": model.ToolFailed, "error_msg": msg, "last_check": &now,
|
||||
})
|
||||
tool.Status = model.ToolFailed
|
||||
tool.ErrorMsg = msg
|
||||
}
|
||||
h.db.Model(&tool).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"installed_version": version,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{
|
||||
"available": available,
|
||||
"version": version,
|
||||
"available": false,
|
||||
"version": "",
|
||||
"tool": tool,
|
||||
}})
|
||||
}
|
||||
@@ -186,7 +301,7 @@ func (h *ToolConfigHandler) CheckToolStatus(c *gin.Context) {
|
||||
// GetToolVersions returns available versions for a tool (jdk/node/maven)
|
||||
func (h *ToolConfigHandler) GetToolVersions(c *gin.Context) {
|
||||
var tool model.DeployTool
|
||||
if err := h.db.First(&tool, c.Param("id")); err.Error != nil {
|
||||
if err := h.db.First(&tool, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "工具不存在"})
|
||||
return
|
||||
}
|
||||
@@ -212,9 +327,12 @@ func getPresetVersions(toolName string) []map[string]string {
|
||||
}
|
||||
case "nodejs":
|
||||
return []map[string]string{
|
||||
{"version": "v18.20.5", "label": "Node 18 (LTS)"},
|
||||
{"version": "v20.18.1", "label": "Node 20 (LTS)"},
|
||||
{"version": "v22.14.0", "label": "Node 22 (LTS) ★ 推荐"},
|
||||
{"version": "lts", "label": "最新 LTS (nvm install --lts) ★ 推荐"},
|
||||
{"version": "v24.18.0", "label": "Node 24.18.0 (LTS Krypton)"},
|
||||
{"version": "v22.23.1", "label": "Node 22.23.1 (LTS Jod)"},
|
||||
{"version": "v20.20.2", "label": "Node 20.20.2 (LTS Iron)"},
|
||||
{"version": "v18.20.8", "label": "Node 18.20.8 (LTS Hydrogen)"},
|
||||
{"version": "v16.20.2", "label": "Node 16.20.2 (LTS Gallium)"},
|
||||
}
|
||||
case "maven":
|
||||
return []map[string]string{
|
||||
@@ -222,7 +340,20 @@ func getPresetVersions(toolName string) []map[string]string {
|
||||
{"version": "3.8.8", "label": "Maven 3.8.8"},
|
||||
{"version": "3.6.3", "label": "Maven 3.6.3"},
|
||||
}
|
||||
case "golang":
|
||||
return []map[string]string{
|
||||
{"version": "1.24.0", "label": "Go 1.24.0 ★ 推荐(与平台后端一致)"},
|
||||
{"version": "1.23.6", "label": "Go 1.23.6 (LTS)"},
|
||||
{"version": "1.22.12", "label": "Go 1.22.12"},
|
||||
{"version": "1.21.13", "label": "Go 1.21.13"},
|
||||
}
|
||||
case "nvm":
|
||||
return []map[string]string{
|
||||
{"version": "0.40.1", "label": "NVM 0.40.1 ★ 推荐"},
|
||||
{"version": "0.39.7", "label": "NVM 0.39.7"},
|
||||
{"version": "0.38.0", "label": "NVM 0.38.0"},
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user