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:
Logan committed 2026-07-02 07:47:33 +08:00
1 parent cde7bbe540
commit 7e1c8f583c
84 files changed
+7729 -1507

No files matched your search

+37 -23
View File
@@ -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))
+1 -1
View File
@@ -67,7 +67,7 @@ func (e *AlertEngine) Check() {
e.checkMiddlewareAlerts()
e.checkSNMPAlerts()
e.checkSiteAlerts()
e.autoResolveSiteSslAlerts()
e.autoResolveSiteAlerts()
e.checkLogAlertRules()
e.autoResolveAlerts()
e.checkLogAlertRecovery()
+67 -9
View File
@@ -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,
})
+224
View File
@@ -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,
},
})
}
+66
View File
@@ -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
View File
@@ -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)
+480
View File
@@ -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))
}
+201
View File
@@ -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),
}})
}
+17 -5
View File
@@ -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,
+417 -118
View File
@@ -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(&reg, *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(&reg, *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 使用)
+263
View File
@@ -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
}
+6 -4
View File
@@ -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)
+15 -3
View File
@@ -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,
+173 -42
View File
@@ -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
}
}
}
+18 -2
View File
@@ -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
}
+61
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
}
}
+72
View File
@@ -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"`
}
+15
View File
@@ -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")
}
+227
View File
@@ -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)
}
+107
View File
@@ -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 {
+24
View File
@@ -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,
})
}
+41
View File
@@ -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
}
+16 -7
View File
@@ -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)
}
+246 -39
View File
@@ -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)
}
}
+6
View File
@@ -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 返回工具的镜像源列表(按网络环境排序)。
+193
View File
@@ -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))
}
}
+30 -1
View File
@@ -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)