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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func PipelineAccess(db *gorm.DB) gin.HandlerFunc {
|
||||
func() uint { uid, _ := c.Get("userID"); return uid.(uint) }()).
|
||||
Find(&itemRoles)
|
||||
for _, ir := range itemRoles {
|
||||
if matched, _ := regexp.MatchString(ir.Pattern, code); matched {
|
||||
if pipelineMatchesItemRole(pipeline, ir.Pattern) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func GetAccessiblePipelineIDs(db *gorm.DB, username string, userID uint) ([]uint
|
||||
db.Find(&allPipelines)
|
||||
for _, p := range allPipelines {
|
||||
for _, ir := range itemRoles {
|
||||
if matched, _ := regexp.MatchString(ir.Pattern, p.Code); matched {
|
||||
if pipelineMatchesItemRole(p, ir.Pattern) {
|
||||
idSet[p.ID] = true
|
||||
break
|
||||
}
|
||||
@@ -137,3 +137,19 @@ func GetAccessiblePipelineIDs(db *gorm.DB, username string, userID uint) ([]uint
|
||||
|
||||
return ids, len(ids) > 0
|
||||
}
|
||||
|
||||
// pipelineMatchesItemRole 正则匹配流水线 code 或 Job 名称(Webhook Job) // Logan.Li
|
||||
func pipelineMatchesItemRole(p model.Pipeline, pattern string) bool {
|
||||
if pattern == "" {
|
||||
return false
|
||||
}
|
||||
if matched, err := regexp.MatchString(pattern, p.Code); err == nil && matched {
|
||||
return true
|
||||
}
|
||||
if p.JobName != "" {
|
||||
if matched, err := regexp.MatchString(pattern, p.JobName); err == nil && matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// BuildNode CI/CD 构建节点(Jenkins Agent 风格,基于 SSH 关联服务器资产) // Logan.Li
|
||||
type BuildNode struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:64;not null;uniqueIndex" json:"name"`
|
||||
Description string `gorm:"size:255" json:"description"`
|
||||
ServerID uint `gorm:"not null;index" json:"server_id"` // FK → servers
|
||||
Labels string `gorm:"size:255;comment:标签(逗号分隔,用于调度匹配)" json:"labels,omitempty"`
|
||||
MaxExecutors int `gorm:"default:2;comment:最大并发构建数" json:"max_executors"`
|
||||
WorkspacePath string `gorm:"size:255;default:/data/aiops-workspace;comment:远程工作区根目录" json:"workspace_path"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// 运行时统计(非持久化,由调度器填充)
|
||||
RunningCount int `gorm:"-" json:"running_count,omitempty"`
|
||||
Hostname string `gorm:"-" json:"hostname,omitempty"`
|
||||
IP string `gorm:"-" json:"ip,omitempty"`
|
||||
}
|
||||
|
||||
func (BuildNode) TableName() string {
|
||||
return "deploy_build_nodes"
|
||||
}
|
||||
|
||||
// BuildMasterConfig Master 内置构建节点并发配置(单行) // Logan.Li
|
||||
type BuildMasterConfig struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
MaxExecutors int `gorm:"default:2;not null;comment:最大并发(1=串行排队)" json:"max_executors"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (BuildMasterConfig) TableName() string { return "deploy_build_master_config" }
|
||||
|
||||
// UpdateBuildMasterRequest 更新 Master 并发数 // Logan.Li
|
||||
type UpdateBuildMasterRequest struct {
|
||||
MaxExecutors int `json:"max_executors" binding:"required,min=1,max=32"`
|
||||
}
|
||||
|
||||
// CreateBuildNodeRequest 创建构建节点
|
||||
type CreateBuildNodeRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
ServerID uint `json:"server_id" binding:"required"`
|
||||
Labels string `json:"labels"`
|
||||
MaxExecutors int `json:"max_executors"`
|
||||
WorkspacePath string `json:"workspace_path"`
|
||||
}
|
||||
|
||||
// UpdateBuildNodeRequest 更新构建节点
|
||||
type UpdateBuildNodeRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ServerID *uint `json:"server_id"`
|
||||
Labels string `json:"labels"`
|
||||
MaxExecutors int `json:"max_executors"`
|
||||
WorkspacePath string `json:"workspace_path"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
+32
-9
@@ -27,12 +27,19 @@ type Pipeline struct {
|
||||
// 并发控制
|
||||
MaxExecutors int `gorm:"default:2;comment:最大并发执行数(1=串行,默认2)" json:"max_executors"`
|
||||
|
||||
// 构建节点(nil=自动调度 Master 优先,非 nil=指定节点 ID) // Logan.Li
|
||||
BuildNodeID *uint `json:"build_node_id,omitempty"`
|
||||
|
||||
// SSH 部署目标服务器 (JSON: [1,2,3])
|
||||
ServerIDs string `gorm:"type:text;comment:SSH服务器ID列表(JSON)" json:"server_ids,omitempty"`
|
||||
ServerIDs string `gorm:"type:text;comment:SSH服务器ID列表(JSON)" json:"server_ids,omitempty"`
|
||||
DeployRemotePath string `gorm:"size:255;comment:SSH部署远程目录" json:"deploy_remote_path,omitempty"`
|
||||
DeployScript string `gorm:"type:text;comment:SSH部署后执行命令" json:"deploy_script,omitempty"`
|
||||
|
||||
// K8s 部署目标
|
||||
K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → kubernetes_clusters
|
||||
K8sNamespace string `gorm:"size:128;comment:K8s部署命名空间" json:"k8s_namespace,omitempty"`
|
||||
K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → kubernetes_clusters
|
||||
K8sNamespace string `gorm:"size:128;comment:K8s部署命名空间" json:"k8s_namespace,omitempty"`
|
||||
K8sDeployment string `gorm:"size:128;comment:K8s Deployment 名称" json:"k8s_deployment,omitempty"`
|
||||
K8sContainerName string `gorm:"size:128;comment:K8s更新镜像目标容器" json:"k8s_container_name,omitempty"`
|
||||
|
||||
// 仓库和镜像仓库关联(旧字段,待废弃)
|
||||
RepoID *uint `json:"repo_id"` // FK → deploy_pipeline_repos
|
||||
@@ -87,6 +94,8 @@ type PipelineRun struct {
|
||||
ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签
|
||||
ArtifactURL string `gorm:"size:500" json:"artifact_url"` // 构建产物地址
|
||||
WorkspacePath string `gorm:"size:500" json:"workspace_path"` // 工作区路径
|
||||
BuildNodeID *uint `json:"build_node_id,omitempty"` // 实际执行节点(0/nil=Master)
|
||||
BuildNodeName string `gorm:"size:64" json:"build_node_name,omitempty"`
|
||||
Duration int `json:"duration"` // 秒
|
||||
Stages string `gorm:"type:text" json:"stages"` // JSON
|
||||
Logs string `gorm:"type:longtext" json:"logs"`
|
||||
@@ -153,9 +162,13 @@ type CreatePipelineRequest struct {
|
||||
// 部署模式
|
||||
DeployMode string `json:"deploy_mode"` // branch / tag
|
||||
DeployTarget string `json:"deploy_target"` // ssh / k8s
|
||||
ServerIDs string `json:"server_ids"` // SSH 服务器ID JSON [1,2,3]
|
||||
K8sClusterID *uint `json:"k8s_cluster_id"`
|
||||
K8sNamespace string `json:"k8s_namespace"`
|
||||
ServerIDs string `json:"server_ids"` // SSH 服务器ID JSON [1,2,3]
|
||||
DeployRemotePath string `json:"deploy_remote_path"`
|
||||
DeployScript string `json:"deploy_script"`
|
||||
K8sClusterID *uint `json:"k8s_cluster_id"`
|
||||
K8sNamespace string `json:"k8s_namespace"`
|
||||
K8sDeployment string `json:"k8s_deployment"`
|
||||
K8sContainerName string `json:"k8s_container_name"`
|
||||
|
||||
RepoID *uint `json:"repo_id"`
|
||||
RegistryID *uint `json:"registry_id"`
|
||||
@@ -176,6 +189,9 @@ type CreatePipelineRequest struct {
|
||||
|
||||
// 并发控制
|
||||
MaxExecutors int `json:"max_executors"`
|
||||
|
||||
// 构建节点
|
||||
BuildNodeID *uint `json:"build_node_id"`
|
||||
}
|
||||
|
||||
// UpdatePipelineRequest 更新流水线请求
|
||||
@@ -191,9 +207,13 @@ type UpdatePipelineRequest struct {
|
||||
Variables string `json:"variables"`
|
||||
DeployMode string `json:"deploy_mode"`
|
||||
DeployTarget string `json:"deploy_target"`
|
||||
ServerIDs string `json:"server_ids"`
|
||||
K8sClusterID *uint `json:"k8s_cluster_id"`
|
||||
K8sNamespace string `json:"k8s_namespace"`
|
||||
ServerIDs string `json:"server_ids"`
|
||||
DeployRemotePath string `json:"deploy_remote_path"`
|
||||
DeployScript string `json:"deploy_script"`
|
||||
K8sClusterID *uint `json:"k8s_cluster_id"`
|
||||
K8sNamespace string `json:"k8s_namespace"`
|
||||
K8sDeployment string `json:"k8s_deployment"`
|
||||
K8sContainerName string `json:"k8s_container_name"`
|
||||
RepoID *uint `json:"repo_id"`
|
||||
RegistryID *uint `json:"registry_id"`
|
||||
// 凭证关联
|
||||
@@ -212,6 +232,9 @@ type UpdatePipelineRequest struct {
|
||||
|
||||
// 并发控制
|
||||
MaxExecutors int `json:"max_executors"`
|
||||
|
||||
// 构建节点(传 null 清除绑定)
|
||||
BuildNodeID *uint `json:"build_node_id"`
|
||||
}
|
||||
|
||||
// TriggerPipelineRequest 触发流水线请求
|
||||
|
||||
+211
-50
@@ -74,26 +74,65 @@ func PredefinedTools() []DeployTool {
|
||||
InstallMethod: ToolLazy,
|
||||
BinaryName: "java",
|
||||
InstallPath: "/usr/local/bin/java",
|
||||
CheckCommand: "java -version 2>&1 | head -1",
|
||||
CheckCommand: "/usr/local/bin/java -version 2>&1 | head -1",
|
||||
VersionURL: "https://api.adoptium.net/v3/info/release_versions?heap_size=normal&image_type=jdk&page=0&page_size=1&project=jdk&release_type=ga&semver=false&sort_method=DEFAULT&sort_order=DESC&version=%5B17%2C99%5D",
|
||||
DownloadURL: "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-{version}/OpenJDK17U-jdk_x64_linux_hotspot_{version}.tar.gz",
|
||||
DefaultVersion: "17.0.13+7",
|
||||
InstallScript: `#!/bin/sh
|
||||
# JDK lazy install — Temurin OpenJDK
|
||||
# JDK lazy install — Alpine apk 优先,否则 Adoptium API 多镜像 // Logan.Li
|
||||
JDK_VERSION="${TOOL_VERSION:-17.0.13+7}"
|
||||
JDK_MAJOR=$(echo "$JDK_VERSION" | cut -d. -f1)
|
||||
JDK_URL="https://github.com/adoptium/temurin${JDK_MAJOR}-binaries/releases/download/jdk-${JDK_VERSION}/OpenJDK${JDK_MAJOR}U-jdk_x64_linux_hotspot_${JDK_VERSION}.tar.gz"
|
||||
echo "[JDK] Downloading ${JDK_VERSION}..."
|
||||
cd /tmp
|
||||
curl -sL "$JDK_URL" -o jdk.tar.gz
|
||||
JDK_FILE_VER=$(echo "$JDK_VERSION" | tr '+' '_')
|
||||
echo "[JDK] Installing ${JDK_VERSION} (major ${JDK_MAJOR})..."
|
||||
|
||||
if command -v apk >/dev/null 2>&1; then
|
||||
case "$JDK_MAJOR" in
|
||||
11) APK_PKG="openjdk11-jdk" ;;
|
||||
17) APK_PKG="openjdk17-jdk" ;;
|
||||
21) APK_PKG="openjdk21-jdk" ;;
|
||||
*) APK_PKG="" ;;
|
||||
esac
|
||||
if [ -n "$APK_PKG" ]; then
|
||||
echo "[JDK] Trying apk: $APK_PKG"
|
||||
if apk add --no-cache "$APK_PKG"; then
|
||||
JAVA_BIN=$(command -v java 2>/dev/null || echo "/usr/lib/jvm/java-${JDK_MAJOR}-openjdk/bin/java")
|
||||
mkdir -p /usr/local/bin /usr/local/share/jdk/bin
|
||||
ln -sf "$JAVA_BIN" /usr/local/bin/java
|
||||
ln -sf "$(dirname "$JAVA_BIN")/javac" /usr/local/bin/javac 2>/dev/null || true
|
||||
ln -sf "$(dirname "$JAVA_BIN")/jar" /usr/local/bin/jar 2>/dev/null || true
|
||||
echo "[JDK] Installed via apk: $(java -version 2>&1 | head -1)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
cd /tmp && rm -rf jdk.tar.gz jdk-install
|
||||
mkdir -p jdk-install && cd jdk-install
|
||||
DOWNLOADED=0
|
||||
for URL in \
|
||||
"https://api.adoptium.net/v3/binary/version/${JDK_VERSION}/linux/x64/jdk/hotspot/normal/eclipse?project=jdk" \
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/Adoptium/${JDK_MAJOR}/jdk/${JDK_VERSION}/OpenJDK${JDK_MAJOR}U-jdk_x64_linux_hotspot_${JDK_FILE_VER}.tar.gz" \
|
||||
"https://github.com/adoptium/temurin${JDK_MAJOR}-binaries/releases/download/jdk-${JDK_VERSION}/OpenJDK${JDK_MAJOR}U-jdk_x64_linux_hotspot_${JDK_FILE_VER}.tar.gz"
|
||||
do
|
||||
echo "[JDK] Trying $URL"
|
||||
if curl -fsSL -L -o jdk.tar.gz "$URL"; then
|
||||
DOWNLOADED=1
|
||||
break
|
||||
fi
|
||||
rm -f jdk.tar.gz
|
||||
done
|
||||
[ "$DOWNLOADED" -eq 1 ] || { echo "[JDK] all mirrors failed"; exit 1; }
|
||||
tar xzf jdk.tar.gz
|
||||
JDK_DIR=$(find . -maxdepth 1 -type d -name 'jdk-*' | head -1)
|
||||
[ -n "$JDK_DIR" ] || { echo "[JDK] extract dir not found"; exit 1; }
|
||||
mkdir -p /usr/local/share
|
||||
mv jdk-${JDK_VERSION} /usr/local/share/jdk
|
||||
rm -rf /usr/local/share/jdk
|
||||
mv "$JDK_DIR" /usr/local/share/jdk
|
||||
ln -sf /usr/local/share/jdk/bin/java /usr/local/bin/java
|
||||
ln -sf /usr/local/share/jdk/bin/javac /usr/local/bin/javac
|
||||
ln -sf /usr/local/share/jdk/bin/jar /usr/local/bin/jar
|
||||
rm -f jdk.tar.gz
|
||||
echo "[JDK] Installed: $(java -version 2>&1 | head -1)"
|
||||
cd /tmp && rm -rf jdk-install
|
||||
echo "[JDK] Installed: $(/usr/local/bin/java -version 2>&1 | head -1)"
|
||||
`,
|
||||
SortOrder: 1,
|
||||
},
|
||||
@@ -106,26 +145,100 @@ echo "[JDK] Installed: $(java -version 2>&1 | head -1)"
|
||||
InstallMethod: ToolLazy,
|
||||
BinaryName: "mvn",
|
||||
InstallPath: "/usr/local/bin/mvn",
|
||||
CheckCommand: "mvn --version 2>/dev/null",
|
||||
CheckCommand: "/usr/local/bin/mvn --version 2>/dev/null | head -1",
|
||||
DownloadURL: "https://dlcdn.apache.org/maven/maven-3/{version}/binaries/apache-maven-{version}-bin.tar.gz",
|
||||
VersionURL: "https://dlcdn.apache.org/maven/maven-3/",
|
||||
DefaultVersion: "3.9.9",
|
||||
InstallScript: `#!/bin/sh
|
||||
# Maven lazy install — download & extract
|
||||
# Maven lazy install — 多镜像轮询 // Logan.Li
|
||||
MAVEN_VERSION="${TOOL_VERSION:-3.9.9}"
|
||||
MAVEN_URL="https://dlcdn.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz"
|
||||
echo "[Maven] Downloading ${MAVEN_VERSION}..."
|
||||
cd /tmp
|
||||
curl -sL "$MAVEN_URL" -o maven.tar.gz
|
||||
cd /tmp && rm -rf maven.tar.gz maven-install
|
||||
mkdir -p maven-install && cd maven-install
|
||||
DOWNLOADED=0
|
||||
for URL in \
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/apache/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \
|
||||
"https://repo.huaweicloud.com/apache/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \
|
||||
"https://dlcdn.apache.org/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz" \
|
||||
"https://archive.apache.org/dist/maven/maven-3/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz"
|
||||
do
|
||||
echo "[Maven] Trying $URL"
|
||||
if curl -fsSL -o maven.tar.gz "$URL"; then
|
||||
DOWNLOADED=1
|
||||
break
|
||||
fi
|
||||
rm -f maven.tar.gz
|
||||
done
|
||||
[ "$DOWNLOADED" -eq 1 ] || { echo "[Maven] all mirrors failed"; exit 1; }
|
||||
tar xzf maven.tar.gz
|
||||
MVN_DIR=$(find . -maxdepth 1 -type d -name 'apache-maven-*' | head -1)
|
||||
[ -n "$MVN_DIR" ] || { echo "[Maven] extract dir not found"; exit 1; }
|
||||
mkdir -p /usr/local/share
|
||||
mv apache-maven-${MAVEN_VERSION} /usr/local/share/maven
|
||||
rm -rf /usr/local/share/maven
|
||||
mv "$MVN_DIR" /usr/local/share/maven
|
||||
ln -sf /usr/local/share/maven/bin/mvn /usr/local/bin/mvn
|
||||
rm -f maven.tar.gz
|
||||
echo "[Maven] Installed: $(mvn --version 2>&1 | head -1)"
|
||||
cd /tmp && rm -rf maven-install
|
||||
echo "[Maven] Installed: $(/usr/local/bin/mvn --version 2>&1 | head -1)"
|
||||
`,
|
||||
SortOrder: 2,
|
||||
},
|
||||
// ──── Go — 后端构建 // Logan.Li ────
|
||||
{
|
||||
Name: "golang",
|
||||
DisplayName: "Go (Golang)",
|
||||
Description: "Go 语言工具链,流水线 go build/test 阶段使用",
|
||||
Category: "build",
|
||||
InstallMethod: ToolLazy,
|
||||
BinaryName: "go",
|
||||
InstallPath: "/usr/local/bin/go",
|
||||
CheckCommand: "/usr/local/bin/go version 2>/dev/null",
|
||||
DownloadURL: "https://go.dev/dl/go{version}.linux-amd64.tar.gz",
|
||||
DefaultVersion: "1.24.0",
|
||||
InstallScript: `#!/bin/sh
|
||||
# Go lazy install — 国内镜像优先 // Logan.Li
|
||||
GO_VERSION="${TOOL_VERSION:-1.24.0}"
|
||||
GO_VERSION="${GO_VERSION#go}"
|
||||
echo "[Go] Installing go${GO_VERSION}..."
|
||||
cd /tmp && rm -rf go.tar.gz go-install
|
||||
mkdir -p go-install && cd go-install
|
||||
DOWNLOADED=0
|
||||
for URL in \
|
||||
"https://golang.google.cn/dl/go${GO_VERSION}.linux-amd64.tar.gz" \
|
||||
"https://mirrors.aliyun.com/golang/go${GO_VERSION}.linux-amd64.tar.gz" \
|
||||
"https://mirrors.ustc.edu.cn/golang/go${GO_VERSION}.linux-amd64.tar.gz" \
|
||||
"https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz"
|
||||
do
|
||||
echo "[Go] Trying $URL"
|
||||
if curl -fsSL -L -o go.tar.gz "$URL"; then
|
||||
DOWNLOADED=1
|
||||
break
|
||||
fi
|
||||
rm -f go.tar.gz
|
||||
done
|
||||
[ "$DOWNLOADED" -eq 1 ] || { echo "[Go] all mirrors failed"; exit 1; }
|
||||
tar xzf go.tar.gz
|
||||
[ -d go ] || { echo "[Go] extract dir not found"; exit 1; }
|
||||
rm -rf /usr/local/go
|
||||
mv go /usr/local/go
|
||||
mkdir -p /usr/local/bin
|
||||
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||||
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||||
mkdir -p /etc/profile.d
|
||||
cat > /etc/profile.d/golang.sh << 'GO_PROFILE'
|
||||
export GOROOT=/usr/local/go
|
||||
export PATH=$GOROOT/bin:$PATH
|
||||
export GOPROXY=https://goproxy.cn,direct
|
||||
export GOTOOLCHAIN=local
|
||||
GO_PROFILE
|
||||
chmod +x /etc/profile.d/golang.sh
|
||||
export GOROOT=/usr/local/go
|
||||
export PATH=$GOROOT/bin:$PATH
|
||||
export GOPROXY=https://goproxy.cn,direct
|
||||
export GOTOOLCHAIN=local
|
||||
echo "[Go] Installed: $(/usr/local/bin/go version)"
|
||||
`,
|
||||
SortOrder: 3,
|
||||
},
|
||||
// ──── NVM — Node Version Manager // Logan.Li ────
|
||||
{
|
||||
Name: "nvm",
|
||||
@@ -135,26 +248,30 @@ echo "[Maven] Installed: $(mvn --version 2>&1 | head -1)"
|
||||
InstallMethod: ToolLazy,
|
||||
BinaryName: "nvm",
|
||||
InstallPath: "/usr/local/share/nvm/nvm.sh",
|
||||
CheckCommand: "source /usr/local/share/nvm/nvm.sh 2>/dev/null && nvm --version 2>/dev/null",
|
||||
CheckCommand: ". /usr/local/share/nvm/nvm.sh 2>/dev/null && nvm --version 2>/dev/null",
|
||||
VersionURL: "https://api.github.com/repos/nvm-sh/nvm/releases/latest",
|
||||
DefaultVersion: "0.40.1",
|
||||
InstallScript: `#!/bin/sh
|
||||
# NVM lazy install
|
||||
# NVM lazy install — 使用 bash 执行官方脚本 // Logan.Li
|
||||
NVM_VERSION="${TOOL_VERSION:-0.40.1}"
|
||||
NVM_DIR="/usr/local/share/nvm"
|
||||
echo "[NVM] Downloading ${NVM_VERSION}..."
|
||||
echo "[NVM] Downloading v${NVM_VERSION}..."
|
||||
mkdir -p "$NVM_DIR"
|
||||
curl -sL "https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh" | NVM_DIR="$NVM_DIR" bash
|
||||
# Make available system-wide via profile.d
|
||||
curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/v${NVM_VERSION}/install.sh" -o /tmp/nvm-install.sh
|
||||
export NVM_DIR
|
||||
bash /tmp/nvm-install.sh
|
||||
rm -f /tmp/nvm-install.sh
|
||||
[ -s "$NVM_DIR/nvm.sh" ] || { echo "[NVM] nvm.sh not found"; exit 1; }
|
||||
mkdir -p /etc/profile.d
|
||||
cat > /etc/profile.d/nvm.sh << 'NVM_PROFILE'
|
||||
export NVM_DIR="/usr/local/share/nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||
NVM_PROFILE
|
||||
chmod +x /etc/profile.d/nvm.sh
|
||||
echo "[NVM] Installed: v${NVM_VERSION}"
|
||||
. "$NVM_DIR/nvm.sh"
|
||||
echo "[NVM] Installed: $(nvm --version 2>/dev/null || echo v${NVM_VERSION})"
|
||||
`,
|
||||
SortOrder: 3,
|
||||
SortOrder: 4,
|
||||
},
|
||||
// ──── Node.js — 通过 NVM 安装 // Logan.Li ────
|
||||
{
|
||||
@@ -166,30 +283,74 @@ echo "[NVM] Installed: v${NVM_VERSION}"
|
||||
BinaryName: "node",
|
||||
InstallPath: "/usr/local/bin/node",
|
||||
CheckCommand: "node --version 2>/dev/null",
|
||||
DefaultVersion: "v22.14.0",
|
||||
DefaultVersion: "lts",
|
||||
InstallScript: `#!/bin/sh
|
||||
# Node.js via NVM install
|
||||
NODE_VERSION="${TOOL_VERSION:-v22.14.0}"
|
||||
echo "[Node.js] Installing via NVM ${NODE_VERSION}..."
|
||||
export NVM_DIR="/usr/local/share/nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
if ! command -v nvm >/dev/null 2>&1; then
|
||||
# NVM not found — install it first
|
||||
echo "[Node.js] NVM not found, installing NVM first..."
|
||||
curl -sL "https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh" | NVM_DIR="$NVM_DIR" bash
|
||||
\. "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
nvm install "${NODE_VERSION#v}"
|
||||
nvm alias default "${NODE_VERSION#v}"
|
||||
nvm use "${NODE_VERSION#v}"
|
||||
# Symlink for system-wide access
|
||||
ln -sf "$NVM_DIR/versions/node/$(nvm version)/bin/node" /usr/local/bin/node
|
||||
ln -sf "$NVM_DIR/versions/node/$(nvm version)/bin/npm" /usr/local/bin/npm
|
||||
ln -sf "$NVM_DIR/versions/node/$(nvm version)/bin/npx" /usr/local/bin/npx
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
echo "[Node.js] Installed: $(node --version) / npm $(npm --version)"
|
||||
# Node.js 安装 — Alpine 用 unofficial-builds musl 包(官方已停 musl 预编译)// Logan.Li
|
||||
NODE_VERSION="${TOOL_VERSION:-lts}"
|
||||
NVM_DIR="/usr/local/share/nvm"
|
||||
NODE_MIRROR="${NODE_MIRROR:-https://npmmirror.com/mirrors/node}"
|
||||
echo "[Node.js] Installing (${NODE_VERSION})..."
|
||||
mkdir -p "$NVM_DIR/versions/node"
|
||||
export NVM_DIR
|
||||
|
||||
resolve_version() {
|
||||
case "$1" in
|
||||
lts)
|
||||
curl -fsSL "https://nodejs.org/dist/index.json" 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(next(x['version'] for x in d if x.get('lts')))" 2>/dev/null || echo "v24.18.0"
|
||||
;;
|
||||
latest|node)
|
||||
curl -fsSL "https://nodejs.org/dist/index.json" 2>/dev/null | python3 -c "import json,sys; print(json.load(sys.stdin)[0]['version'])" 2>/dev/null || echo "v24.18.0"
|
||||
;;
|
||||
*)
|
||||
echo "v${1#v}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
direct_install_binary() {
|
||||
RESOLVED="$1"
|
||||
DEST="$NVM_DIR/versions/node/${RESOLVED}"
|
||||
TMP="/tmp/node-dl-$$"
|
||||
rm -rf "$TMP" "$DEST"
|
||||
mkdir -p "$TMP"
|
||||
if [ -f /etc/alpine-release ]; then
|
||||
# Node 官方仅 glibc;Alpine musl 走 unofficial-builds // Logan.Li
|
||||
URLS="https://unofficial-builds.nodejs.org/download/release/${RESOLVED}/node-${RESOLVED}-linux-x64-musl.tar.xz"
|
||||
else
|
||||
URLS="${NODE_MIRROR}/${RESOLVED}/node-${RESOLVED}-linux-x64.tar.xz https://nodejs.org/dist/${RESOLVED}/node-${RESOLVED}-linux-x64.tar.xz"
|
||||
fi
|
||||
for URL in $URLS; do
|
||||
echo "[Node.js] Downloading $URL"
|
||||
rm -rf "$TMP"/* 2>/dev/null || true
|
||||
if curl -fsSL -L "$URL" | tar -xJ -C "$TMP" 2>/dev/null; then
|
||||
SUB=$(find "$TMP" -mindepth 1 -maxdepth 1 -type d | head -1)
|
||||
if [ -n "$SUB" ] && [ -x "$SUB/bin/node" ]; then
|
||||
mv "$SUB" "$DEST"
|
||||
rm -rf "$TMP"
|
||||
echo "[Node.js] Installed $RESOLVED"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
echo "[Node.js] Failed: $URL"
|
||||
done
|
||||
rm -rf "$TMP"
|
||||
return 1
|
||||
}
|
||||
|
||||
RESOLVED=$(resolve_version "$NODE_VERSION")
|
||||
[ -n "$RESOLVED" ] || { echo "[Node.js] cannot resolve version for $NODE_VERSION"; exit 1; }
|
||||
echo "[Node.js] Target version: $RESOLVED"
|
||||
direct_install_binary "$RESOLVED" || { echo "[Node.js] binary install failed"; exit 1; }
|
||||
|
||||
INST="$RESOLVED"
|
||||
mkdir -p /usr/local/bin
|
||||
ln -sf "$NVM_DIR/versions/node/${INST}/bin/node" /usr/local/bin/node
|
||||
ln -sf "$NVM_DIR/versions/node/${INST}/bin/npm" /usr/local/bin/npm
|
||||
ln -sf "$NVM_DIR/versions/node/${INST}/bin/npx" /usr/local/bin/npx
|
||||
"$NVM_DIR/versions/node/${INST}/bin/npm" config set registry https://registry.npmmirror.com
|
||||
echo "[Node.js] Installed: $(/usr/local/bin/node --version) / npm $("$NVM_DIR/versions/node/${INST}/bin/npm" --version)"
|
||||
`,
|
||||
SortOrder: 4,
|
||||
SortOrder: 5,
|
||||
},
|
||||
// ──── Git — 版本控制(懒加载, U4 镜像瘦身)// Logan.Li ────
|
||||
{
|
||||
@@ -202,7 +363,7 @@ echo "[Node.js] Installed: $(node --version) / npm $(npm --version)"
|
||||
InstallPath: "/usr/bin/git",
|
||||
CheckCommand: "git --version 2>/dev/null",
|
||||
InstallScript: `apk add --no-cache git`,
|
||||
SortOrder: 5,
|
||||
SortOrder: 6,
|
||||
},
|
||||
// ──── Docker CLI — 容器构建(懒加载, U4 镜像瘦身)// Logan.Li ────
|
||||
{
|
||||
@@ -215,7 +376,7 @@ echo "[Node.js] Installed: $(node --version) / npm $(npm --version)"
|
||||
InstallPath: "/usr/bin/docker",
|
||||
CheckCommand: "docker --version 2>/dev/null",
|
||||
InstallScript: `apk add --no-cache docker-cli`,
|
||||
SortOrder: 6,
|
||||
SortOrder: 7,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// LogScopeRole 日志查询范围权限(正则匹配 ES 索引 / Loki label) // Logan.Li
|
||||
type LogScopeRole struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Name string `gorm:"size:64;not null" json:"name"`
|
||||
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
|
||||
Engine string `gorm:"size:32;default:*;comment:引擎 loki/elasticsearch/*" json:"engine"`
|
||||
MatchField string `gorm:"size:32;not null;comment:匹配维度 index/app/namespace/job" json:"match_field"`
|
||||
Pattern string `gorm:"size:255;not null;comment:正则表达式" json:"pattern"`
|
||||
DatasourceID *uint `json:"datasource_id,omitempty"`
|
||||
Role string `gorm:"size:16;default:viewer;comment:viewer/analyst" json:"role"`
|
||||
Description string `gorm:"size:255" json:"description"`
|
||||
Status int `gorm:"default:1;comment:0禁用1启用" json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (LogScopeRole) TableName() string { return "monitor_log_scope_roles" }
|
||||
|
||||
// UserLogScopeRole 用户 ↔ 日志范围权限 // Logan.Li
|
||||
type UserLogScopeRole struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_user_log_scope;not null" json:"user_id"`
|
||||
LogScopeRoleID uint `gorm:"uniqueIndex:idx_user_log_scope;not null" json:"log_scope_role_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
LogScopeRole *LogScopeRole `json:"log_scope_role,omitempty" gorm:"foreignKey:LogScopeRoleID"`
|
||||
}
|
||||
|
||||
func (UserLogScopeRole) TableName() string { return "monitor_user_log_scope_roles" }
|
||||
|
||||
// CreateLogScopeRoleRequest 创建日志权限策略 // Logan.Li
|
||||
type CreateLogScopeRoleRequest struct {
|
||||
Name string `json:"name" binding:"required,max=64"`
|
||||
Code string `json:"code" binding:"required,max=64"`
|
||||
Engine string `json:"engine" binding:"omitempty,oneof=loki elasticsearch *"`
|
||||
MatchField string `json:"match_field" binding:"required,oneof=index app namespace job"`
|
||||
Pattern string `json:"pattern" binding:"required,max=255"`
|
||||
DatasourceID *uint `json:"datasource_id"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=viewer analyst"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// UpdateLogScopeRoleRequest 更新日志权限策略 // Logan.Li
|
||||
type UpdateLogScopeRoleRequest struct {
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Engine string `json:"engine"`
|
||||
MatchField string `json:"match_field"`
|
||||
Pattern string `json:"pattern"`
|
||||
DatasourceID *uint `json:"datasource_id"`
|
||||
Role string `json:"role"`
|
||||
Description string `json:"description"`
|
||||
Status *int `json:"status"`
|
||||
}
|
||||
|
||||
// AssignLogScopeRoleRequest 用户授权(user_id 取自 URL 路径)// Logan.Li
|
||||
type AssignLogScopeRoleRequest struct {
|
||||
LogScopeRoleID uint `json:"log_scope_role_id" binding:"required"`
|
||||
}
|
||||
|
||||
// PreviewLogScopeRequest 命中预览 // Logan.Li
|
||||
type PreviewLogScopeRequest struct {
|
||||
Engine string `json:"engine" binding:"required,oneof=loki elasticsearch"`
|
||||
DatasourceID uint `json:"datasource_id"`
|
||||
Index string `json:"index"`
|
||||
Query string `json:"query"`
|
||||
SampleValue string `json:"sample_value"`
|
||||
}
|
||||
@@ -99,6 +99,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.MetricData{},
|
||||
&model.Pipeline{},
|
||||
&model.PipelineRun{},
|
||||
&model.BuildNode{},
|
||||
&model.AppRelease{},
|
||||
&model.CronJob{},
|
||||
&model.Script{},
|
||||
@@ -157,6 +158,9 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
|
||||
&model.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
|
||||
&model.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
|
||||
&model.LogScopeRole{}, // 日志查询范围权限(正则匹配索引/Label)// Logan.Li
|
||||
&model.UserLogScopeRole{}, // 用户 ↔ 日志范围权限 // Logan.Li
|
||||
&model.BuildMasterConfig{}, // Master 构建节点并发配置 // Logan.Li
|
||||
&model.DeployTool{}, // 工具配置(懒加载/预装/禁用) // Logan.Li
|
||||
&model.AlertCondition{}, // 告警条件 // Logan.Li
|
||||
&model.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
|
||||
@@ -216,4 +220,15 @@ func applySchemaPatches(db *gorm.DB) {
|
||||
patchLogDSColumn("AuthType", "auth_type")
|
||||
patchLogDSColumn("Token", "token")
|
||||
patchLogDSColumn("Headers", "headers")
|
||||
|
||||
// 日志权限关联表:AutoMigrate 在 MySQL 索引冲突时可能只创建策略表未创建用户关联表 // Logan.Li
|
||||
ensureTable := func(tbl interface{}, name string) {
|
||||
if !m.HasTable(tbl) {
|
||||
if err := db.AutoMigrate(tbl); err != nil {
|
||||
log.Printf("[DB] 补表 %s 失败: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureTable(&model.LogScopeRole{}, "monitor_log_scope_roles")
|
||||
ensureTable(&model.UserLogScopeRole{}, "monitor_user_log_scope_roles")
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package logaccess
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// QueryContext 日志查询授权上下文 // Logan.Li
|
||||
type QueryContext struct {
|
||||
Engine string
|
||||
DatasourceID uint
|
||||
Index string
|
||||
Query string
|
||||
}
|
||||
|
||||
var (
|
||||
lokiSelectorRe = regexp.MustCompile(`\{([^}]*)\}`)
|
||||
lokiLabelRe = regexp.MustCompile(`([a-zA-Z_][\w.-]*)\s*(=|!=|=~|!~)\s*("([^"]*)"|'([^']*)'|([^\s,}]+))`)
|
||||
esAppRe = regexp.MustCompile(`(?i)\bapp:(\*|"[^"]+"|'[^']+'|[^\s)]+)`)
|
||||
)
|
||||
|
||||
// GetUserLogScopeRoles 获取用户已启用的日志权限策略 // Logan.Li
|
||||
func GetUserLogScopeRoles(db *gorm.DB, userID uint) ([]model.LogScopeRole, error) {
|
||||
var roles []model.LogScopeRole
|
||||
err := db.Table("monitor_log_scope_roles").
|
||||
Joins("JOIN monitor_user_log_scope_roles ON monitor_user_log_scope_roles.log_scope_role_id = monitor_log_scope_roles.id").
|
||||
Where("monitor_user_log_scope_roles.user_id = ? AND monitor_log_scope_roles.status = 1", userID).
|
||||
Find(&roles).Error
|
||||
return roles, err
|
||||
}
|
||||
|
||||
// UserHasAssignments 用户是否已分配日志权限(未分配则保持兼容:不拦截) // Logan.Li
|
||||
func UserHasAssignments(db *gorm.DB, userID uint) bool {
|
||||
var n int64
|
||||
db.Model(&model.UserLogScopeRole{}).Where("user_id = ?", userID).Count(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// CanQuery 校验用户是否允许执行该日志查询 // Logan.Li
|
||||
func CanQuery(db *gorm.DB, userID uint, isAdmin bool, qc QueryContext) (bool, string) {
|
||||
if isAdmin {
|
||||
return true, ""
|
||||
}
|
||||
if !UserHasAssignments(db, userID) {
|
||||
return true, ""
|
||||
}
|
||||
roles, err := GetUserLogScopeRoles(db, userID)
|
||||
if err != nil || len(roles) == 0 {
|
||||
return false, "未分配可用的日志查询权限,请联系管理员"
|
||||
}
|
||||
for _, r := range roles {
|
||||
if roleAllowsQuery(r, qc) {
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
return false, "无权查询该日志范围(索引/Label 未命中授权策略)"
|
||||
}
|
||||
|
||||
// FilterDataSources 按授权过滤可见数据源 // Logan.Li
|
||||
func FilterDataSources(db *gorm.DB, userID uint, isAdmin bool, sources []model.LogDataSource) []model.LogDataSource {
|
||||
if isAdmin || !UserHasAssignments(db, userID) {
|
||||
return sources
|
||||
}
|
||||
roles, _ := GetUserLogScopeRoles(db, userID)
|
||||
if len(roles) == 0 {
|
||||
return []model.LogDataSource{}
|
||||
}
|
||||
out := make([]model.LogDataSource, 0, len(sources))
|
||||
for _, ds := range sources {
|
||||
if datasourceAllowed(roles, ds) {
|
||||
out = append(out, ds)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MatchRolesForPreview 返回命中预览的策略列表 // Logan.Li
|
||||
func MatchRolesForPreview(roles []model.LogScopeRole, qc QueryContext, sample string) []model.LogScopeRole {
|
||||
matched := make([]model.LogScopeRole, 0)
|
||||
for _, r := range roles {
|
||||
if roleAllowsQuery(r, qc) {
|
||||
matched = append(matched, r)
|
||||
continue
|
||||
}
|
||||
if sample != "" && regexpMatch(r.Pattern, sample) && engineMatches(r.Engine, qc.Engine) {
|
||||
if r.DatasourceID == nil || *r.DatasourceID == 0 || *r.DatasourceID == qc.DatasourceID {
|
||||
if fieldMatchesEngine(r.MatchField, qc.Engine) {
|
||||
matched = append(matched, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
|
||||
func datasourceAllowed(roles []model.LogScopeRole, ds model.LogDataSource) bool {
|
||||
for _, r := range roles {
|
||||
if !engineMatches(r.Engine, ds.Type) {
|
||||
continue
|
||||
}
|
||||
if r.DatasourceID == nil || *r.DatasourceID == 0 || *r.DatasourceID == ds.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func roleAllowsQuery(r model.LogScopeRole, qc QueryContext) bool {
|
||||
if !engineMatches(r.Engine, qc.Engine) {
|
||||
return false
|
||||
}
|
||||
if r.DatasourceID != nil && *r.DatasourceID > 0 && *r.DatasourceID != qc.DatasourceID {
|
||||
return false
|
||||
}
|
||||
switch r.MatchField {
|
||||
case "index":
|
||||
if qc.Engine != "elasticsearch" {
|
||||
return false
|
||||
}
|
||||
idx := strings.TrimSpace(qc.Index)
|
||||
if idx == "" {
|
||||
idx = "*"
|
||||
}
|
||||
return regexpMatch(r.Pattern, idx)
|
||||
case "app", "namespace", "job":
|
||||
values := extractFieldValues(qc.Engine, qc.Query, r.MatchField)
|
||||
if len(values) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range values {
|
||||
if regexpMatch(r.Pattern, v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func engineMatches(roleEngine, queryEngine string) bool {
|
||||
re := strings.ToLower(strings.TrimSpace(roleEngine))
|
||||
qe := strings.ToLower(strings.TrimSpace(queryEngine))
|
||||
if re == "" || re == "*" {
|
||||
return true
|
||||
}
|
||||
return re == qe
|
||||
}
|
||||
|
||||
func fieldMatchesEngine(field, engine string) bool {
|
||||
if field == "index" {
|
||||
return engine == "elasticsearch"
|
||||
}
|
||||
return engine == "loki"
|
||||
}
|
||||
|
||||
func regexpMatch(pattern, value string) bool {
|
||||
if pattern == "" || value == "" {
|
||||
return false
|
||||
}
|
||||
matched, err := regexp.MatchString(pattern, value)
|
||||
return err == nil && matched
|
||||
}
|
||||
|
||||
func extractFieldValues(engine, query, field string) []string {
|
||||
if engine == "loki" {
|
||||
return extractLokiLabelValues(query, field)
|
||||
}
|
||||
if field == "app" {
|
||||
return extractESAppValues(query)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractLokiLabelValues(query, field string) []string {
|
||||
m := lokiSelectorRe.FindStringSubmatch(query)
|
||||
if len(m) < 2 {
|
||||
return nil
|
||||
}
|
||||
inner := m[1]
|
||||
var values []string
|
||||
for _, part := range lokiLabelRe.FindAllStringSubmatch(inner, -1) {
|
||||
if len(part) < 7 || part[1] != field {
|
||||
continue
|
||||
}
|
||||
op := part[2]
|
||||
val := part[4]
|
||||
if val == "" {
|
||||
val = part[5]
|
||||
}
|
||||
if val == "" {
|
||||
val = part[6]
|
||||
}
|
||||
if op == "=" || op == "=~" {
|
||||
values = append(values, val)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func extractESAppValues(query string) []string {
|
||||
var values []string
|
||||
for _, m := range esAppRe.FindAllStringSubmatch(query, -1) {
|
||||
if len(m) < 2 {
|
||||
continue
|
||||
}
|
||||
v := strings.Trim(m[1], `"'`)
|
||||
if v != "" {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// DescribeRole 策略可读描述 // Logan.Li
|
||||
func DescribeRole(r model.LogScopeRole) string {
|
||||
ds := "全部数据源"
|
||||
if r.DatasourceID != nil && *r.DatasourceID > 0 {
|
||||
ds = fmt.Sprintf("数据源#%d", *r.DatasourceID)
|
||||
}
|
||||
return fmt.Sprintf("%s · %s · %s=%s", r.Engine, ds, r.MatchField, r.Pattern)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package remoteexec
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
@@ -191,6 +192,112 @@ func UploadFile(cfg SSHConfig, localPath, remotePath string) (*UploadResult, err
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DownloadFile 从远程主机下载文件到本地(二进制安全,供流水线回拉产物) // Logan.Li
|
||||
func DownloadFile(cfg SSHConfig, remotePath, localPath string) error {
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建SSH会话失败: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取stdout失败: %v", err)
|
||||
}
|
||||
|
||||
remoteDir := filepath.Dir(remotePath)
|
||||
mkdirCmd := fmt.Sprintf("mkdir -p %s", remoteDir)
|
||||
_, _ = execWithClient(client, cfg, mkdirCmd, 30*time.Second)
|
||||
|
||||
if err := session.Start(fmt.Sprintf("cat %s", shellQuote(remotePath))); err != nil {
|
||||
return fmt.Errorf("启动远程读取失败: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(localPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(f, stdout); err != nil {
|
||||
return fmt.Errorf("写入本地文件失败: %v", err)
|
||||
}
|
||||
if err := session.Wait(); err != nil {
|
||||
return fmt.Errorf("远程读取失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamRemoteTar 将远程目录打包为 tar.gz 流式写入本地文件 // Logan.Li
|
||||
func StreamRemoteTar(cfg SSHConfig, remoteDir, localTarPath string, timeout time.Duration) error {
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Minute
|
||||
}
|
||||
client, err := connect(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建SSH会话失败: %v", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取stdout失败: %v", err)
|
||||
}
|
||||
|
||||
cmd := fmt.Sprintf("tar czf - -C %s .", shellQuote(remoteDir))
|
||||
if err := session.Start(cmd); err != nil {
|
||||
return fmt.Errorf("启动远程打包失败: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(localTarPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(localTarPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, copyErr := io.Copy(f, stdout)
|
||||
done <- copyErr
|
||||
}()
|
||||
|
||||
select {
|
||||
case copyErr := <-done:
|
||||
if copyErr != nil {
|
||||
return fmt.Errorf("写入 tar 失败: %v", copyErr)
|
||||
}
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("下载工作区超时")
|
||||
}
|
||||
if err := session.Wait(); err != nil {
|
||||
return fmt.Errorf("远程打包失败: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellQuote 简单 shell 路径转义 // Logan.Li
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
|
||||
// BatchUpload 批量上传文件到多台服务器(并发)。
|
||||
func BatchUpload(servers []SSHConfig, localPath, remotePath string, maxConcurrency int) []UploadResult {
|
||||
if maxConcurrency <= 0 {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package siteprobe
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ResolveRecoveredProbeAlerts 拨测恢复后立即关闭探测类活跃告警 // Logan.Li
|
||||
func ResolveRecoveredProbeAlerts(db *gorm.DB, taskID uint, lastResult string) {
|
||||
if lastResult != "success" {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
db.Model(&model.SiteAlert{}).
|
||||
Where("task_id = ? AND status = ? AND trigger_type IN ?",
|
||||
taskID, model.SiteAlertStatusActive,
|
||||
[]string{"probe_fail", "probe_timeout", "http_status_error"}).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.SiteAlertStatusResolved, "resolved_at": now,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package siteprobe
|
||||
|
||||
import "strings"
|
||||
|
||||
// DefaultProbeFrequency 各协议推荐默认拨测间隔(秒)// Logan.Li
|
||||
func DefaultProbeFrequency(taskType string) int {
|
||||
switch strings.ToLower(taskType) {
|
||||
case "ssl":
|
||||
return 3600 // 证书日级变化,1 小时足够
|
||||
case "dns":
|
||||
return 300 // 5 分钟
|
||||
case "tcp", "ping":
|
||||
return 120 // 2 分钟
|
||||
default:
|
||||
return 60 // HTTP 1 分钟
|
||||
}
|
||||
}
|
||||
|
||||
// MinProbeFrequency 各协议最小拨测间隔(秒),防止频率过高压垮平台 // Logan.Li
|
||||
func MinProbeFrequency(taskType string) int {
|
||||
switch strings.ToLower(taskType) {
|
||||
case "ssl":
|
||||
return 600 // 最少 10 分钟
|
||||
case "dns":
|
||||
return 60
|
||||
default:
|
||||
return 30
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeProbeFrequency 规范化拨测频率 // Logan.Li
|
||||
func NormalizeProbeFrequency(taskType string, freq int) int {
|
||||
if freq <= 0 {
|
||||
return DefaultProbeFrequency(taskType)
|
||||
}
|
||||
min := MinProbeFrequency(taskType)
|
||||
if freq < min {
|
||||
return min
|
||||
}
|
||||
return freq
|
||||
}
|
||||
@@ -67,15 +67,22 @@ func (w *Worker) runOnce() {
|
||||
var tasks []model.SiteTask
|
||||
w.db.Where("status = ?", model.SiteTaskStatusRunning).Find(&tasks)
|
||||
now := time.Now()
|
||||
sem := make(chan struct{}, 8) // 最多 8 路并发拨测,避免串行阻塞 // Logan.Li
|
||||
var wg sync.WaitGroup
|
||||
for _, task := range tasks {
|
||||
if task.Frequency <= 0 {
|
||||
task.Frequency = 60
|
||||
}
|
||||
task.Frequency = NormalizeProbeFrequency(string(task.TaskType), task.Frequency)
|
||||
if task.LastCheckAt != nil && now.Sub(*task.LastCheckAt) < time.Duration(task.Frequency)*time.Second {
|
||||
continue
|
||||
}
|
||||
w.probeTask(task)
|
||||
wg.Add(1)
|
||||
go func(t model.SiteTask) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
w.probeTask(t)
|
||||
}(task)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (w *Worker) probeTask(task model.SiteTask) {
|
||||
@@ -110,9 +117,11 @@ func (w *Worker) probeTask(task model.SiteTask) {
|
||||
avg = (task.AvgResponse + outcome.ResponseTime) / 2
|
||||
}
|
||||
w.db.Model(&model.SiteTask{}).Where("id = ?", task.ID).Updates(map[string]interface{}{
|
||||
"task_type": task.TaskType,
|
||||
"last_result": lastResult,
|
||||
"task_type": task.TaskType,
|
||||
"last_result": lastResult,
|
||||
"last_check_at": now,
|
||||
"avg_response": avg,
|
||||
"avg_response": avg,
|
||||
})
|
||||
// 拨测恢复后即时关闭探测类告警,与统计栏 down_count 同步 // Logan.Li
|
||||
ResolveRecoveredProbeAlerts(w.db, task.ID, lastResult)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package toolinstaller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -45,15 +46,26 @@ func (ti *ToolInstaller) ensureOnce(name string) *sync.Once {
|
||||
return o
|
||||
}
|
||||
|
||||
// ForceInstall unconditionally installs a tool (bypasses sync.Once).
|
||||
// Used by the manual "安装" button in the tool config UI.
|
||||
// The version parameter overrides DefaultVersion if non-empty.
|
||||
func (ti *ToolInstaller) ForceInstall(toolName string, version string) error {
|
||||
// InstallResult 工具安装结果(含脚本输出,供审计日志)// Logan.Li
|
||||
type InstallResult struct {
|
||||
Output string
|
||||
VerifyOut string
|
||||
Installed bool
|
||||
Version string
|
||||
Err error
|
||||
}
|
||||
|
||||
// ForceInstall unconditionally installs a tool(重置 Once,允许重复安装)// Logan.Li
|
||||
func (ti *ToolInstaller) ForceInstall(toolName string, version string) InstallResult {
|
||||
var tool model.DeployTool
|
||||
if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil {
|
||||
return fmt.Errorf("tool %s not found in DB: %w", toolName, err)
|
||||
return InstallResult{Err: fmt.Errorf("tool %s not found in DB: %w", toolName, err)}
|
||||
}
|
||||
|
||||
ti.mu.Lock()
|
||||
delete(ti.onces, toolName)
|
||||
ti.mu.Unlock()
|
||||
|
||||
// Override version if specified
|
||||
if version != "" && version != tool.DefaultVersion {
|
||||
ti.db.Model(&tool).Update("default_version", version)
|
||||
@@ -101,7 +113,8 @@ func (ti *ToolInstaller) EnsureAvailable(toolName string) error {
|
||||
var installErr error
|
||||
once := ti.ensureOnce(toolName)
|
||||
once.Do(func() {
|
||||
installErr = ti.installTool(&tool)
|
||||
res := ti.installTool(&tool)
|
||||
installErr = res.Err
|
||||
})
|
||||
// If install failed, reset the Once so next call can retry
|
||||
if installErr != nil {
|
||||
@@ -115,70 +128,206 @@ func (ti *ToolInstaller) EnsureAvailable(toolName string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// checkInstalled runs the tool's check command and returns the version string.
|
||||
func (ti *ToolInstaller) checkInstalled(tool model.DeployTool) (string, error) {
|
||||
// First try LookPath
|
||||
if tool.BinaryName != "" {
|
||||
// VerifyTool 检测工具是否已安装(文件存在 + 可执行验证,不依赖 PATH)// Logan.Li
|
||||
func (ti *ToolInstaller) VerifyTool(tool model.DeployTool) (bool, string) {
|
||||
switch tool.Name {
|
||||
case "jdk":
|
||||
for _, p := range []string{"/usr/local/bin/java", "/usr/local/share/jdk/bin/java"} {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
cmd := exec.Command(p, "-version")
|
||||
out, _ := cmd.CombinedOutput()
|
||||
ver := strings.TrimSpace(strings.Split(string(out), "\n")[0])
|
||||
if ver != "" {
|
||||
return true, ver
|
||||
}
|
||||
return true, "installed"
|
||||
}
|
||||
}
|
||||
case "maven":
|
||||
for _, p := range []string{"/usr/local/bin/mvn", "/usr/local/share/maven/bin/mvn"} {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
cmd := exec.Command(p, "--version")
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return true, strings.TrimSpace(strings.Split(string(out), "\n")[0])
|
||||
}
|
||||
return true, "installed"
|
||||
}
|
||||
}
|
||||
case "nvm":
|
||||
nvmSh := "/usr/local/share/nvm/nvm.sh"
|
||||
if st, err := os.Stat(nvmSh); err == nil && !st.IsDir() {
|
||||
cmd := exec.Command("bash", "-c", fmt.Sprintf(`. "%s" && nvm --version`, nvmSh))
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return true, strings.TrimSpace(string(out))
|
||||
}
|
||||
return true, tool.DefaultVersion
|
||||
}
|
||||
case "golang":
|
||||
for _, p := range []string{"/usr/local/bin/go", "/usr/local/go/bin/go"} {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
cmd := exec.Command(p, "version")
|
||||
out, _ := cmd.CombinedOutput()
|
||||
ver := strings.TrimSpace(string(out))
|
||||
if ver != "" {
|
||||
return true, ver
|
||||
}
|
||||
return true, "installed"
|
||||
}
|
||||
}
|
||||
case "nodejs":
|
||||
for _, p := range []string{"/usr/local/bin/node"} {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
cmd := exec.Command(p, "--version")
|
||||
out, _ := cmd.CombinedOutput()
|
||||
ver := strings.TrimSpace(string(out))
|
||||
if ver != "" {
|
||||
return true, ver
|
||||
}
|
||||
return true, "installed"
|
||||
}
|
||||
}
|
||||
default:
|
||||
if ver, err := ti.runToolCheck(tool); err == nil {
|
||||
return true, ver
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// SyncToolStatus 根据磁盘实际状态刷新 DB(修复审计成功但页面仍失败/安装中)// Logan.Li
|
||||
func (ti *ToolInstaller) SyncToolStatus(toolID uint) model.DeployTool {
|
||||
var tool model.DeployTool
|
||||
if err := ti.db.First(&tool, toolID).Error; err != nil {
|
||||
return tool
|
||||
}
|
||||
ok, ver := ti.VerifyTool(tool)
|
||||
if ok {
|
||||
ti.updateStatus(tool.ID, model.ToolInstalled, ver, "")
|
||||
tool.Status = model.ToolInstalled
|
||||
tool.InstalledVer = ver
|
||||
tool.ErrorMsg = ""
|
||||
} else if tool.Status == model.ToolInstalling {
|
||||
// 保持安装中,由超时逻辑处理
|
||||
} else if tool.Status == model.ToolInstalled {
|
||||
// 已标记安装但文件不存在 — 降为未安装
|
||||
ti.updateStatus(tool.ID, model.ToolNotInstalled, "", "")
|
||||
tool.Status = model.ToolNotInstalled
|
||||
}
|
||||
ti.db.First(&tool, toolID)
|
||||
return tool
|
||||
}
|
||||
|
||||
// runToolCheck 执行 PATH/检测命令(不回调 VerifyTool,避免递归栈溢出)// Logan.Li
|
||||
func (ti *ToolInstaller) runToolCheck(tool model.DeployTool) (string, error) {
|
||||
if tool.BinaryName != "" && tool.BinaryName != "nvm" {
|
||||
if _, err := exec.LookPath(tool.BinaryName); err != nil {
|
||||
return "", fmt.Errorf("binary %s not found", tool.BinaryName)
|
||||
}
|
||||
}
|
||||
|
||||
// Run check command if provided
|
||||
if tool.CheckCommand != "" {
|
||||
cmd := exec.Command("sh", "-c", tool.CheckCommand)
|
||||
shell := "sh"
|
||||
if tool.Name == "nvm" || tool.Name == "nodejs" {
|
||||
shell = "bash"
|
||||
}
|
||||
cmd := exec.Command(shell, "-c", tool.CheckCommand)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(strings.Split(string(out), "\n")[0]), nil
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
if len(lines) == 0 {
|
||||
return "", fmt.Errorf("tool %s check empty output", tool.Name)
|
||||
}
|
||||
return strings.TrimSpace(lines[0]), nil
|
||||
}
|
||||
return "", fmt.Errorf("tool %s not available", tool.Name)
|
||||
}
|
||||
|
||||
return "unknown", nil
|
||||
// checkInstalled runs the tool's check command and returns the version string.
|
||||
func (ti *ToolInstaller) checkInstalled(tool model.DeployTool) (string, error) {
|
||||
if ok, ver := ti.VerifyTool(tool); ok {
|
||||
return ver, nil
|
||||
}
|
||||
return ti.runToolCheck(tool)
|
||||
}
|
||||
|
||||
// installTool performs the actual installation.
|
||||
func (ti *ToolInstaller) installTool(tool *model.DeployTool) error {
|
||||
func (ti *ToolInstaller) installTool(tool *model.DeployTool) InstallResult {
|
||||
log.Printf("[ToolInstaller] Installing %s (method=%s)...", tool.Name, tool.InstallMethod)
|
||||
|
||||
// Mark as installing
|
||||
ti.updateStatus(tool.ID, model.ToolInstalling, "", "")
|
||||
|
||||
var installOut string
|
||||
var err error
|
||||
if tool.InstallScript != "" {
|
||||
err = ti.runInstallScript(tool)
|
||||
installOut, err = ti.runInstallScript(tool)
|
||||
} else if tool.DownloadURL != "" {
|
||||
err = ti.downloadBinary(tool)
|
||||
installOut, err = ti.downloadBinary(tool)
|
||||
} else {
|
||||
err = fmt.Errorf("no install script or download URL configured for %s", tool.Name)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
errMsg := err.Error()
|
||||
if installOut != "" {
|
||||
errMsg = installOut + "\n---\n" + errMsg
|
||||
}
|
||||
log.Printf("[ToolInstaller] ERROR installing %s: %s", tool.Name, errMsg)
|
||||
ti.updateStatus(tool.ID, model.ToolFailed, "", errMsg)
|
||||
return fmt.Errorf("failed to install %s: %w", tool.Name, err)
|
||||
ti.updateStatus(tool.ID, model.ToolFailed, "", truncateInstallLog(errMsg, 512))
|
||||
return InstallResult{Output: installOut, Err: fmt.Errorf("failed to install %s: %w", tool.Name, err)}
|
||||
}
|
||||
|
||||
// Verify installation
|
||||
ver, checkErr := ti.checkInstalled(*tool)
|
||||
if checkErr != nil {
|
||||
log.Printf("[ToolInstaller] WARNING: %s installed but check failed: %v", tool.Name, checkErr)
|
||||
ti.updateStatus(tool.ID, model.ToolInstalled, "unknown", "")
|
||||
} else {
|
||||
// 安装后验证(优先文件检测,避免 PATH/source 问题)// Logan.Li
|
||||
verifyOut := ti.captureVerifyOutput(*tool)
|
||||
if ok, ver := ti.VerifyTool(*tool); ok {
|
||||
log.Printf("[ToolInstaller] %s installed successfully: %s", tool.Name, ver)
|
||||
ti.updateStatus(tool.ID, model.ToolInstalled, ver, "")
|
||||
return InstallResult{Output: installOut, VerifyOut: verifyOut, Installed: true, Version: ver}
|
||||
}
|
||||
log.Printf("[ToolInstaller] WARNING: %s script ok but verify failed", tool.Name)
|
||||
failMsg := "安装脚本执行完成但验证未通过,请点击「测试」重新检测"
|
||||
ti.updateStatus(tool.ID, model.ToolFailed, "", failMsg)
|
||||
return InstallResult{
|
||||
Output: installOut, VerifyOut: verifyOut,
|
||||
Err: fmt.Errorf("verify failed after install %s", tool.Name),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
// captureVerifyOutput 执行验证命令并捕获输出(供审计)// Logan.Li
|
||||
func (ti *ToolInstaller) captureVerifyOutput(tool model.DeployTool) string {
|
||||
if tool.CheckCommand == "" {
|
||||
return ""
|
||||
}
|
||||
shell := "sh"
|
||||
if tool.Name == "nvm" || tool.Name == "nodejs" {
|
||||
shell = "bash"
|
||||
}
|
||||
cmd := exec.Command(shell, "-c", tool.CheckCommand)
|
||||
out, err := cmd.CombinedOutput()
|
||||
text := strings.TrimSpace(string(out))
|
||||
if err != nil && text == "" {
|
||||
return err.Error()
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func truncateInstallLog(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) > n {
|
||||
return s[:n] + "..."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// runInstallScript executes the tool's install script in a subshell.
|
||||
func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) error {
|
||||
func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) (string, error) {
|
||||
// Write script to temp file
|
||||
tmpFile, err := os.CreateTemp("", fmt.Sprintf("tool-install-%s-*.sh", tool.Name))
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create temp script: %w", err)
|
||||
return "", fmt.Errorf("cannot create temp script: %w", err)
|
||||
}
|
||||
defer os.Remove(tmpFile.Name())
|
||||
|
||||
@@ -189,21 +338,28 @@ func (ti *ToolInstaller) runInstallScript(tool *model.DeployTool) error {
|
||||
|
||||
script := fmt.Sprintf("#!/bin/sh\nset -e\nTOOL_VERSION=%s\n%s\n", version, tool.InstallScript)
|
||||
if _, err := tmpFile.WriteString(script); err != nil {
|
||||
return fmt.Errorf("cannot write temp script: %w", err)
|
||||
return "", fmt.Errorf("cannot write temp script: %w", err)
|
||||
}
|
||||
tmpFile.Close()
|
||||
os.Chmod(tmpFile.Name(), 0755)
|
||||
|
||||
cmd := exec.Command("sh", tmpFile.Name())
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
shell := "sh"
|
||||
if tool.Name == "nvm" || tool.Name == "nodejs" {
|
||||
shell = "bash"
|
||||
}
|
||||
cmd := exec.Command(shell, tmpFile.Name())
|
||||
var buf bytes.Buffer
|
||||
// 同时输出到容器日志便于排障 // Logan.Li
|
||||
cmd.Stdout = io.MultiWriter(os.Stdout, &buf)
|
||||
cmd.Stderr = io.MultiWriter(os.Stderr, &buf)
|
||||
err = cmd.Run()
|
||||
return buf.String(), err
|
||||
}
|
||||
|
||||
// downloadBinary downloads a binary file to the install path.
|
||||
// Uses multi-mirror fallback: tries each mirror URL in order, with HEAD verification
|
||||
// to skip dead mirrors before attempting a full download.
|
||||
func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) error {
|
||||
func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) (string, error) {
|
||||
version := tool.DefaultVersion
|
||||
if version == "" {
|
||||
// Try to get latest version
|
||||
@@ -223,24 +379,28 @@ func (ti *ToolInstaller) downloadBinary(tool *model.DeployTool) error {
|
||||
// Resolve mirror URLs — domestic mirrors prioritized when in China
|
||||
mirrorURLs := GetMirrorsForTool(tool.Name, tool.DownloadURL)
|
||||
if len(mirrorURLs) == 0 {
|
||||
return fmt.Errorf("no download URLs configured for %s", tool.Name)
|
||||
return "", fmt.Errorf("no download URLs configured for %s", tool.Name)
|
||||
}
|
||||
|
||||
// Try each mirror in order
|
||||
var lastErr error
|
||||
var logBuf strings.Builder
|
||||
for i, urlTemplate := range mirrorURLs {
|
||||
url := strings.ReplaceAll(urlTemplate, "{version}", version)
|
||||
log.Printf("[ToolInstaller] Downloading %s from mirror[%d]: %s", tool.Name, i, url)
|
||||
logBuf.WriteString(fmt.Sprintf("[%d] %s\n", i, url))
|
||||
|
||||
err := ti.tryDownload(url, tool)
|
||||
if err == nil {
|
||||
return nil // success
|
||||
logBuf.WriteString(fmt.Sprintf("OK: downloaded from mirror[%d]\n", i))
|
||||
return logBuf.String(), nil
|
||||
}
|
||||
lastErr = err
|
||||
logBuf.WriteString(fmt.Sprintf("FAIL mirror[%d]: %v\n", i, err))
|
||||
log.Printf("[ToolInstaller] Mirror[%d] failed for %s: %v, trying next...", i, tool.Name, err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("all %d mirrors exhausted for %s, last error: %w", len(mirrorURLs), tool.Name, lastErr)
|
||||
return logBuf.String(), fmt.Errorf("all %d mirrors exhausted for %s, last error: %w", len(mirrorURLs), tool.Name, lastErr)
|
||||
}
|
||||
|
||||
// tryDownload attempts a single download from a URL.
|
||||
@@ -314,10 +474,12 @@ func (ti *ToolInstaller) updateStatus(toolID uint, status model.ToolStatus, vers
|
||||
"last_check": &now,
|
||||
}
|
||||
if version != "" {
|
||||
updates["installed_version"] = version
|
||||
updates["installed_ver"] = version // 与 GORM 列名一致 // Logan.Li
|
||||
}
|
||||
if errMsg != "" {
|
||||
updates["error_msg"] = errMsg
|
||||
} else if status == model.ToolInstalled {
|
||||
updates["error_msg"] = ""
|
||||
}
|
||||
ti.db.Model(&model.DeployTool{}).Where("id = ?", toolID).Updates(updates)
|
||||
}
|
||||
@@ -393,4 +555,49 @@ func (ti *ToolInstaller) UpgradeTools() {
|
||||
"description": up.description,
|
||||
})
|
||||
}
|
||||
|
||||
// 补全新增工具(如 golang)并校正排序 // Logan.Li
|
||||
now := time.Now()
|
||||
for _, def := range model.PredefinedTools() {
|
||||
var existing model.DeployTool
|
||||
if err := ti.db.Where("name = ?", def.Name).First(&existing).Error; err != nil {
|
||||
def.CreatedAt = now
|
||||
def.UpdatedAt = now
|
||||
def.Status = model.ToolNotInstalled
|
||||
if err := ti.db.Create(&def).Error; err != nil {
|
||||
log.Printf("[ToolInstaller] Failed to seed missing tool %s: %v", def.Name, err)
|
||||
} else {
|
||||
log.Printf("[ToolInstaller] Added missing tool: %s", def.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
sortBumps := map[string]int{"jdk": 1, "maven": 2, "golang": 3, "nvm": 4, "nodejs": 5, "git": 6, "docker-cli": 7}
|
||||
for name, order := range sortBumps {
|
||||
ti.db.Model(&model.DeployTool{}).Where("name = ?", name).Update("sort_order", order)
|
||||
}
|
||||
|
||||
// 同步 JDK/Maven/Go/NVM 安装脚本与检测命令修复 // Logan.Li
|
||||
type toolPatch struct {
|
||||
script string
|
||||
check string
|
||||
}
|
||||
patchSync := map[string]toolPatch{}
|
||||
for _, t := range model.PredefinedTools() {
|
||||
if t.Name == "jdk" || t.Name == "maven" || t.Name == "golang" || t.Name == "nvm" || t.Name == "nodejs" {
|
||||
patchSync[t.Name] = toolPatch{script: t.InstallScript, check: t.CheckCommand}
|
||||
}
|
||||
}
|
||||
for name, p := range patchSync {
|
||||
updates := map[string]interface{}{
|
||||
"install_script": p.script,
|
||||
"check_command": p.check,
|
||||
}
|
||||
if name == "nodejs" {
|
||||
updates["default_version"] = "lts"
|
||||
}
|
||||
if name == "golang" {
|
||||
updates["default_version"] = "1.24.0"
|
||||
}
|
||||
ti.db.Model(&model.DeployTool{}).Where("name = ?", name).Updates(updates)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ var toolMirrors = map[string][]ToolMirror{
|
||||
{Name: "Helm官方", URL: "https://get.helm.sh/helm-{version}-linux-amd64.tar.gz", Location: "global"},
|
||||
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/kubernetes/helm/{version}/helm-{version}-linux-amd64.tar.gz", Location: "domestic"},
|
||||
},
|
||||
"golang": {
|
||||
{Name: "Go官方", URL: "https://go.dev/dl/go{version}.linux-amd64.tar.gz", Location: "global"},
|
||||
{Name: "Google CN", URL: "https://golang.google.cn/dl/go{version}.linux-amd64.tar.gz", Location: "domestic"},
|
||||
{Name: "阿里云镜像", URL: "https://mirrors.aliyun.com/golang/go{version}.linux-amd64.tar.gz", Location: "domestic"},
|
||||
{Name: "中科大镜像", URL: "https://mirrors.ustc.edu.cn/golang/go{version}.linux-amd64.tar.gz", Location: "domestic"},
|
||||
},
|
||||
}
|
||||
|
||||
// GetMirrorsForTool 返回工具的镜像源列表(按网络环境排序)。
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
package toolinstaller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/model"
|
||||
)
|
||||
|
||||
// UninstallResult 工具卸载结果(含脚本输出,供审计日志)// Logan.Li
|
||||
type UninstallResult struct {
|
||||
Output string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Uninstall 卸载指定工具并重置 DB 状态(允许重新安装)// Logan.Li
|
||||
func (ti *ToolInstaller) Uninstall(toolName string) UninstallResult {
|
||||
var tool model.DeployTool
|
||||
if err := ti.db.Where("name = ?", toolName).First(&tool).Error; err != nil {
|
||||
return UninstallResult{Err: fmt.Errorf("tool %s not found in DB: %w", toolName, err)}
|
||||
}
|
||||
|
||||
ti.mu.Lock()
|
||||
delete(ti.onces, toolName)
|
||||
ti.mu.Unlock()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := ti.uninstallTool(&tool, &buf); err != nil {
|
||||
out := strings.TrimSpace(buf.String())
|
||||
log.Printf("[ToolInstaller] ERROR uninstalling %s: %v", toolName, err)
|
||||
ti.resetUninstallStatus(tool.ID, out+": "+err.Error())
|
||||
return UninstallResult{Output: out, Err: err}
|
||||
}
|
||||
|
||||
ti.resetUninstallStatus(tool.ID, "")
|
||||
out := strings.TrimSpace(buf.String())
|
||||
log.Printf("[ToolInstaller] %s uninstalled successfully", toolName)
|
||||
return UninstallResult{Output: out}
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) resetUninstallStatus(toolID uint, errMsg string) {
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"status": model.ToolNotInstalled,
|
||||
"installed_ver": "",
|
||||
"last_check": &now,
|
||||
}
|
||||
if errMsg != "" {
|
||||
updates["error_msg"] = errMsg
|
||||
} else {
|
||||
updates["error_msg"] = ""
|
||||
}
|
||||
ti.updateStatusMap(toolID, updates)
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) updateStatusMap(toolID uint, updates map[string]interface{}) {
|
||||
ti.db.Model(&model.DeployTool{}).Where("id = ?", toolID).Updates(updates)
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallTool(tool *model.DeployTool, buf *bytes.Buffer) error {
|
||||
switch tool.Name {
|
||||
case "jdk":
|
||||
return ti.uninstallJDK(buf)
|
||||
case "maven":
|
||||
return ti.uninstallMaven(buf)
|
||||
case "golang":
|
||||
return ti.uninstallGolang(buf)
|
||||
case "nvm":
|
||||
return ti.uninstallNVM(buf)
|
||||
case "nodejs":
|
||||
return ti.uninstallNodeJS(buf)
|
||||
case "git":
|
||||
return ti.uninstallApkPackage(buf, "git", "git")
|
||||
case "docker-cli":
|
||||
return ti.uninstallApkPackage(buf, "docker-cli", "docker")
|
||||
default:
|
||||
return ti.uninstallBinary(tool, buf)
|
||||
}
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallJDK(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[JDK] Removing symlinks and /usr/local/share/jdk\n")
|
||||
removeIfExists(buf, "/usr/local/bin/java", "/usr/local/bin/javac", "/usr/local/bin/jar")
|
||||
removeDirIfExists(buf, "/usr/local/share/jdk")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallMaven(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[Maven] Removing symlinks and /usr/local/share/maven\n")
|
||||
removeIfExists(buf, "/usr/local/bin/mvn")
|
||||
removeDirIfExists(buf, "/usr/local/share/maven")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallGolang(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[Go] Removing GOROOT and symlinks\n")
|
||||
removeIfExists(buf, "/usr/local/bin/go", "/usr/local/bin/gofmt", "/etc/profile.d/golang.sh")
|
||||
removeDirIfExists(buf, "/usr/local/go")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallNVM(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[NVM] Removing NVM and Node versions\n")
|
||||
removeIfExists(buf, "/usr/local/bin/node", "/usr/local/bin/npm", "/usr/local/bin/npx", "/etc/profile.d/nvm.sh")
|
||||
removeDirIfExists(buf, "/usr/local/share/nvm")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallNodeJS(buf *bytes.Buffer) error {
|
||||
buf.WriteString("[Node.js] Removing symlinks and NVM node versions\n")
|
||||
removeIfExists(buf, "/usr/local/bin/node", "/usr/local/bin/npm", "/usr/local/bin/npx")
|
||||
|
||||
nvmSh := "/usr/local/share/nvm/nvm.sh"
|
||||
if st, err := os.Stat(nvmSh); err == nil && !st.IsDir() {
|
||||
script := fmt.Sprintf(`export NVM_DIR="/usr/local/share/nvm"
|
||||
. "%s"
|
||||
CUR=$(nvm current 2>/dev/null || true)
|
||||
if [ -n "$CUR" ] && [ "$CUR" != "none" ] && [ "$CUR" != "system" ]; then
|
||||
nvm uninstall "$CUR" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "$NVM_DIR/versions/node"
|
||||
`, nvmSh)
|
||||
cmd := exec.Command("bash", "-c", script)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) > 0 {
|
||||
buf.Write(out)
|
||||
}
|
||||
if err != nil {
|
||||
buf.WriteString(fmt.Sprintf("[Node.js] nvm cleanup warning: %v\n", err))
|
||||
}
|
||||
}
|
||||
removeDirIfExists(buf, "/usr/local/share/nvm/versions/node")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallApkPackage(buf *bytes.Buffer, pkg, binary string) error {
|
||||
buf.WriteString(fmt.Sprintf("[%s] Trying apk del %s\n", binary, pkg))
|
||||
if _, err := exec.LookPath("apk"); err != nil {
|
||||
buf.WriteString(fmt.Sprintf("[%s] apk not found, skip package removal\n", binary))
|
||||
return nil
|
||||
}
|
||||
cmd := exec.Command("apk", "del", "--no-cache", pkg)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if len(out) > 0 {
|
||||
buf.Write(out)
|
||||
}
|
||||
if err != nil {
|
||||
// 可能未通过 apk 安装,仅标记卸载完成 // Logan.Li
|
||||
buf.WriteString(fmt.Sprintf("[%s] apk del skipped: %v\n", binary, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ti *ToolInstaller) uninstallBinary(tool *model.DeployTool, buf *bytes.Buffer) error {
|
||||
path := tool.InstallPath
|
||||
if path == "" && tool.BinaryName != "" {
|
||||
path = filepath.Join("/usr/local/bin", tool.BinaryName)
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("no install path configured for %s", tool.Name)
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("[%s] Removing %s\n", tool.Name, path))
|
||||
removeIfExists(buf, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeIfExists(buf *bytes.Buffer, paths ...string) {
|
||||
for _, p := range paths {
|
||||
if err := os.Remove(p); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
buf.WriteString(fmt.Sprintf("remove %s: %v\n", p, err))
|
||||
}
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf("removed %s\n", p))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeDirIfExists(buf *bytes.Buffer, dir string) {
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
buf.WriteString(fmt.Sprintf("remove dir %s: %v\n", dir, err))
|
||||
}
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf("removed dir %s\n", dir))
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,17 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
admin.POST("/users/:id/item-roles", itemRoleHandler.AssignItemRole)
|
||||
admin.DELETE("/users/:id/item-roles/:roleId", itemRoleHandler.RemoveItemRole)
|
||||
|
||||
// 日志查询范围权限(正则匹配 ES 索引 / Loki Label)// Logan.Li
|
||||
logScopeRoleHandler := controller.NewLogScopeRoleHandler(db)
|
||||
admin.GET("/log-scope-roles", logScopeRoleHandler.ListLogScopeRoles)
|
||||
admin.POST("/log-scope-roles", logScopeRoleHandler.CreateLogScopeRole)
|
||||
admin.PUT("/log-scope-roles/:id", logScopeRoleHandler.UpdateLogScopeRole)
|
||||
admin.DELETE("/log-scope-roles/:id", logScopeRoleHandler.DeleteLogScopeRole)
|
||||
admin.GET("/log-scope-roles/users/:id", logScopeRoleHandler.ListUserLogScopeRoles)
|
||||
admin.POST("/log-scope-roles/users/:id", logScopeRoleHandler.AssignLogScopeRole)
|
||||
admin.DELETE("/log-scope-roles/users/:userId/:assignId", logScopeRoleHandler.RemoveLogScopeRole)
|
||||
admin.POST("/log-scope-roles/preview", logScopeRoleHandler.PreviewLogScope)
|
||||
|
||||
// 审计策略(保留天数 + 立即清理) // Logan.Li
|
||||
auditSettingsHandler := controller.NewAuditSettingsHandler(db)
|
||||
admin.GET("/audit-settings", auditSettingsHandler.GetAuditSettings)
|
||||
@@ -653,6 +664,17 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
environments.DELETE("/:id", pipelineHandler.DeleteEnvironment)
|
||||
}
|
||||
|
||||
// 构建节点路由(Jenkins Agent 风格 SSH 节点) // Logan.Li
|
||||
buildNodes := authorized.Group("/deploy/build-nodes")
|
||||
{
|
||||
buildNodes.GET("", pipelineHandler.ListBuildNodes)
|
||||
buildNodes.PUT("/master", pipelineHandler.UpdateBuildMaster)
|
||||
buildNodes.POST("", pipelineHandler.CreateBuildNode)
|
||||
buildNodes.PUT("/:id", pipelineHandler.UpdateBuildNode)
|
||||
buildNodes.DELETE("/:id", pipelineHandler.DeleteBuildNode)
|
||||
buildNodes.POST("/:id/test", pipelineHandler.TestBuildNode)
|
||||
}
|
||||
|
||||
// 应用发布路由
|
||||
releases := authorized.Group("/releases")
|
||||
{
|
||||
@@ -690,6 +712,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
tools.GET("/:id", toolConfigHandler.GetTool)
|
||||
tools.PUT("/:id", toolConfigHandler.UpdateTool)
|
||||
tools.POST("/:id/install", toolConfigHandler.InstallTool)
|
||||
tools.POST("/:id/uninstall", toolConfigHandler.UninstallTool)
|
||||
tools.POST("/:id/check", toolConfigHandler.CheckToolStatus)
|
||||
tools.GET("/:id/versions", toolConfigHandler.GetToolVersions)
|
||||
}
|
||||
@@ -745,7 +768,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
|
||||
// 日志监控路由 // Logan.Li
|
||||
logHandler := controller.NewLogHandler(db)
|
||||
logProxyHandler := controller.NewLogProxyHandler(logDSHandler)
|
||||
logProxyHandler := controller.NewLogProxyHandler(logDSHandler, db)
|
||||
logs := authorized.Group("/logs")
|
||||
{
|
||||
logs.GET("", logHandler.QueryLogs)
|
||||
@@ -754,11 +777,17 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
logs.DELETE("/sources/:id", logHandler.DeleteSource)
|
||||
logs.POST("/loki/query", logProxyHandler.QueryLoki)
|
||||
logs.POST("/elasticsearch/query", logProxyHandler.QueryES)
|
||||
logs.GET("/loki/labels", logProxyHandler.ListLokiLabels)
|
||||
logs.GET("/loki/labels/:name/values", logProxyHandler.ListLokiLabelValues)
|
||||
logs.POST("/loki/histogram", logProxyHandler.LokiHistogram)
|
||||
logs.POST("/elasticsearch/histogram", logProxyHandler.ESHistogram)
|
||||
logs.GET("/elasticsearch/mapping", logProxyHandler.ESMapping)
|
||||
}
|
||||
logDSGroup := authorized.Group("/log-datasources")
|
||||
{
|
||||
logDSGroup.GET("", logDSHandler.List)
|
||||
logDSGroup.GET("/indices", logDSHandler.ListIndices)
|
||||
logDSGroup.GET("/:id/indices", logDSHandler.ListIndicesByID)
|
||||
logDSGroup.GET("/:id", logDSHandler.Get)
|
||||
logDSGroup.POST("", logDSHandler.Create)
|
||||
logDSGroup.PUT("/:id", logDSHandler.Update)
|
||||
|
||||
Reference in new issue
Block a user