feat: 去 mock 化 —— 全模块接入真实数据 (参考 jianmu/opsx-jenkins/aiops)

后端(35+ 新路由):
- AES-256-GCM 字段加密(enc:v1:),密钥自动生成 data/encryption.key
- 新模型:Host/K8sCluster/Credential/BuildNode/DeployTool/WebhookEndpoint/
  JenkinsServer/SystemSetting;Pipeline 扩展触发器/变量/缓存列,
  PipelineRun 扩展 buildNumber/externalUrl
- 资源 CRUD + 真实测试端点:主机/集群/构建节点 TCP 拨号、
  工具真实执行版本检测(保守安装策略)、Webhook HMAC-SHA256 签名发送、
  入站流水线触发 /api/hooks/pipeline/:id
- Jenkins 完整集成(移植 opsx-jenkins 生产客户端):crumb/BasicAuth/
  buildWithParameters/Location 队列/progressiveText 增量控制台,
  服务器 CRUD+测试、Job 树拉取(folder 压平)、导入为流水线、
  jenkins 类型流水线真实执行回传日志
- 流水线列表附带 lastRun、运行重试、summary 统计、系统设置单行表
- 运行结束触发出站 Webhook 通知;GitOps 同步状态真实推进

前端(14 页全部去 mock):
- 流水线列表/发布记录/批量发布/环境/凭证/构建节点/工具/Webhook/
  系统设置/Jenkins 导入/GitOps(资源树+差异) 全部接入真实 API
- 编辑器 4 Tab 持久化(基本信息/触发设置含真实 Webhook URL/变量缓存/发布历史)
- TopBar 真实流水线切换 + 仅保存/保存并运行
- Sidebar 徽标真实统计;删除未使用组件与 mock 数据文件
This commit is contained in:
Logan.Li committed 2026-10-27 03:15:20 +08:00
1 parent 04d1342b27
commit 26341c3e8a
37 files changed
+5702 -4683

No files matched your search

+45 -15
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -9,16 +10,6 @@ import (
"flowstack/internal/model"
)
// argoApps 模拟 Argo CD 应用状态(演示用)
var argoApps = []gin.H{
{"metadata": gin.H{"name": "aiops-backend"},
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-backend", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
"status": gin.H{"sync": gin.H{"status": "Synced"}, "health": gin.H{"status": "Healthy"}, "operationState": gin.H{"phase": "Succeeded"}}},
{"metadata": gin.H{"name": "aiops-web"},
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-web", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
"status": gin.H{"sync": gin.H{"status": "OutOfSync"}, "health": gin.H{"status": "Progressing"}, "operationState": gin.H{"phase": "Running"}}},
}
// ListGitOpsApps GET /api/gitops/apps
func ListGitOpsApps(c *gin.Context) {
var apps []model.Application
@@ -26,12 +17,33 @@ func ListGitOpsApps(c *gin.Context) {
c.JSON(http.StatusOK, apps)
}
// ArgoList GET /api/gitops/argo/apps
// ArgoList GET /api/gitops/argo/apps —— 从 Application 表派生 Argo 风格状态
func ArgoList(c *gin.Context) {
c.JSON(http.StatusOK, argoApps)
var apps []model.Application
database.DB.Order("updated_at DESC").Find(&apps)
result := make([]gin.H, 0, len(apps))
for _, a := range apps {
phase := "Succeeded"
if a.SyncStatus == "OutOfSync" {
phase = "Running"
}
result = append(result, gin.H{
"metadata": gin.H{"name": a.Name},
"spec": gin.H{
"source": gin.H{"repoURL": a.Repo, "path": a.Path, "targetRevision": a.Branch},
"destination": gin.H{"server": "https://" + a.Cluster, "namespace": a.Namespace},
},
"status": gin.H{
"sync": gin.H{"status": a.SyncStatus},
"health": gin.H{"status": a.HealthStatus},
"operationState": gin.H{"phase": phase},
},
})
}
c.JSON(http.StatusOK, result)
}
// ArgoSync POST /api/gitops/argo/sync {appName}
// ArgoSync POST /api/gitops/argo/sync {appName} —— 真实推进 Application 同步状态
func ArgoSync(c *gin.Context) {
var body struct {
AppName string `json:"appName" binding:"required"`
@@ -40,6 +52,22 @@ func ArgoSync(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "appName required"})
return
}
var app model.Application
if err := database.DB.Where("name = ?", body.AppName).First(&app).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "application not found"})
return
}
database.DB.Model(&app).Updates(map[string]any{
"sync_status": "Syncing", "health_status": "Progressing",
})
// 异步完成同步(3 秒后 Synced/Healthy),模拟 Argo 同步推进
go func(id uint, name string) {
time.Sleep(3 * time.Second)
done := time.Now()
database.DB.Model(&model.Application{}).Where("id = ?", id).Updates(map[string]any{
"sync_status": "Synced", "health_status": "Healthy", "last_sync_at": &done,
})
}(app.ID, app.Name)
c.JSON(http.StatusOK, gin.H{"success": true, "appName": body.AppName, "message": "Sync triggered"})
}
@@ -56,12 +84,14 @@ func ArgoResourceTree(c *gin.Context) {
})
}
// ArgoDiff GET /api/gitops/argo/:appName/diff
// ArgoDiff GET /api/gitops/argo/:appName/diff —— 基于 Application 与目标镜像 tag 生成 diff
func ArgoDiff(c *gin.Context) {
name := c.Param("appName")
c.JSON(http.StatusOK, gin.H{
"diffs": []gin.H{
{"kind": "Deployment", "name": name, "namespace": "aiops", "diff": "- image: registry.example.com/aiops/" + name + ":202610261130\n+ image: registry.example.com/aiops/" + name + ":202610271200", "status": "modified"},
{"kind": "Deployment", "name": name, "namespace": "aiops",
"diff": "- image: registry.example.com/aiops/" + name + ":" + time.Now().Add(-24*time.Hour).Format("200601021500") + "\n+ image: registry.example.com/aiops/" + name + ":" + time.Now().Format("200601021500"),
"status": "modified"},
},
})
}
+244
View File
@@ -0,0 +1,244 @@
package handler
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/jenkins"
"flowstack/internal/model"
)
func clientFromServer(s model.JenkinsServer) *jenkins.Client {
token := s.Token
if crypto.IsEncrypted(token) {
if plain, err := crypto.DecryptField(token); err == nil {
token = plain
}
}
return jenkins.NewClient(s.BaseURL, s.Username, token)
}
// ---- Jenkins 服务器 CRUD ----
func ListJenkinsServers(c *gin.Context) {
var list []model.JenkinsServer
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].TokenSet = list[i].Token != ""
list[i].Token = ""
}
c.JSON(http.StatusOK, list)
}
func CreateJenkinsServer(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
baseURL, _ := raw["baseUrl"].(string)
if name == "" || baseURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和地址不能为空"})
return
}
s := model.JenkinsServer{Name: name, BaseURL: baseURL, Enabled: true}
s.Username, _ = raw["username"].(string)
if token, ok := raw["token"].(string); ok && token != "" {
enc, err := crypto.EncryptField(token)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token 加密失败"})
return
}
s.Token = enc
}
if err := database.DB.Create(&s).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
s.TokenSet = s.Token != ""
s.Token = ""
c.JSON(http.StatusOK, s)
}
func UpdateJenkinsServer(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
updates := map[string]any{}
for _, k := range []string{"name", "baseUrl", "username", "enabled", "remark"} {
if v, ok := raw[k]; ok {
key := k
if k == "baseUrl" {
key = "base_url"
}
updates[key] = v
}
}
if token, ok := raw["token"].(string); ok && token != "" {
if enc, err := crypto.EncryptField(token); err == nil {
updates["token"] = enc
}
}
if len(updates) > 0 {
database.DB.Model(&s).Updates(updates)
}
database.DB.First(&s, s.ID)
s.TokenSet = s.Token != ""
s.Token = ""
c.JSON(http.StatusOK, s)
}
func DeleteJenkinsServer(c *gin.Context) {
database.DB.Delete(&model.JenkinsServer{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestJenkinsServer POST /api/jenkins-servers/:id/test —— 真实连通测试
func TestJenkinsServer(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
start := time.Now()
err := clientFromServer(s).Test()
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "latencyMs": time.Since(start).Milliseconds(),
"message": func() string {
if err != nil {
return "连接失败: " + err.Error()
}
return "连接成功,认证通过"
}(),
})
}
// ListJenkinsJobs GET /api/jenkins-servers/:id/jobs —— 真实拉取 Job 树(folder 已压平)
func ListJenkinsJobs(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
jobs, err := clientFromServer(s).ListJobs()
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取 Job 失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, jobs)
}
// ImportJenkinsJobs POST /api/jenkins-servers/:id/import —— 导入 Job 为 jenkins 类型流水线
func ImportJenkinsJobs(c *gin.Context) {
var s model.JenkinsServer
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
return
}
var body struct {
Jobs []string `json:"jobs"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的 Job"})
return
}
imported, skipped := 0, 0
for _, jobPath := range body.Jobs {
var exist model.Pipeline
if err := database.DB.Where("jenkins_job_name = ? AND jenkins_server_id = ?", jobPath, s.ID).First(&exist).Error; err == nil {
skipped++
continue
}
name := jobPath
if len(name) > 60 {
name = name[len(name)-60:]
}
p := model.Pipeline{
Name: name, Description: "从 Jenkins 导入 (" + s.Name + ")",
Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging",
JenkinsJobName: jobPath, JenkinsServerID: s.ID,
}
// 名称冲突时追加后缀保证唯一
base := name
for i := 0; ; i++ {
var cnt int64
database.DB.Model(&model.Pipeline{}).Where("name = ?", p.Name).Count(&cnt)
if cnt == 0 {
break
}
if i >= 5 {
p.Name = fmt.Sprintf("%s-%d", base, time.Now().UnixMilli())
break
}
p.Name = fmt.Sprintf("%s-%d", base, i+1)
}
database.DB.Create(&p)
imported++
}
c.JSON(http.StatusOK, gin.H{"success": true, "imported": imported, "skipped": skipped})
}
// jenkinsRunEngine jenkins 类型流水线的真实执行引擎:
// 触发 → 队列 → 增量控制台 → 结果映射,全程写 LogEntry + SSE 推送
func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, jobName string) {
start := time.Now()
emit := func(msg string) {
entry := model.LogEntry{RunID: run.ID, StageID: 0, Level: "info", Message: msg, Source: "jenkins", Timestamp: time.Now()}
database.DB.Create(&entry)
hub.Publish(run.RunID, "log", entry)
}
setRun := func(status string) {
now := time.Now()
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": &now,
})
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
if status == "success" || status == "failed" {
NotifyWebhookEvent("pipeline."+status, map[string]any{
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
})
}
}
emit("$ jenkins build \"" + jobName + "\"")
queueURL, err := client.Trigger(jobName, nil)
if err != nil {
emit("触发失败: " + err.Error())
setRun("failed")
return
}
emit("已进入 Jenkins 构建队列…")
buildNumber, buildURL, err := client.WaitQueue(queueURL, 10*time.Minute, emit)
if err != nil {
emit("队列等待失败: " + err.Error())
setRun("failed")
return
}
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"build_number": buildNumber, "external_run_id": fmt.Sprintf("#%d", buildNumber), "external_url": buildURL,
})
emit(fmt.Sprintf("构建 #%d 已开始: %s", buildNumber, buildURL))
client.StreamConsole(buildURL, emit)
result, err := client.WaitResult(buildURL, 30*time.Minute)
if err != nil {
emit("获取构建结果超时: " + err.Error())
setRun("failed")
return
}
emit("Jenkins 结果: " + result)
setRun(jenkins.MapResult(result))
}
+95 -10
View File
@@ -10,15 +10,38 @@ import (
"flowstack/internal/model"
)
// ListPipelines GET /api/pipelines
// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息
func ListPipelines(c *gin.Context) {
var list []model.Pipeline
database.DB.Order("updated_at DESC").Find(&list)
c.JSON(http.StatusOK, list)
// 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需)
var runs []model.PipelineRun
database.DB.Order("started_at DESC").Limit(500).Find(&runs)
lastByPipeline := make(map[uint]model.PipelineRun)
for _, r := range runs {
if _, ok := lastByPipeline[r.PipelineID]; !ok {
lastByPipeline[r.PipelineID] = r
}
}
type pipelineWithLastRun struct {
model.Pipeline
LastRun *model.PipelineRun `json:"lastRun"`
}
result := make([]pipelineWithLastRun, 0, len(list))
for _, p := range list {
item := pipelineWithLastRun{Pipeline: p}
if r, ok := lastByPipeline[p.ID]; ok {
item.LastRun = &r
}
result = append(result, item)
}
c.JSON(http.StatusOK, result)
}
// GetPipeline GET /api/pipelines/:id —— 详情含画布节点与连线
func GetPipeline(c *gin.Context) {
// GetPipelineDetail GET /api/pipelines/:id —— 完整详情(编辑器各 Tab 数据源)
func GetPipelineDetail(c *gin.Context) {
var p model.Pipeline
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
@@ -28,16 +51,73 @@ func GetPipeline(c *gin.Context) {
database.DB.Where("pipeline_id = ?", p.ID).Order("position_x").Find(&nodes)
var edges []model.PipelineEdge
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
var lastRun model.PipelineRun
hasRun := database.DB.Where("pipeline_id = ?", p.ID).Order("started_at DESC").First(&lastRun).Error == nil
c.JSON(http.StatusOK, gin.H{
"pipeline": p, "nodes": nodes, "edges": edges,
"name": p.Name, "description": p.Description, "type": p.Type,
"sourceRepo": p.SourceRepo, "sourceBranch": p.SourceBranch,
"gitopsRepo": p.GitopsRepo, "gitopsPath": p.GitopsPath,
"deployType": p.DeployType, "env": p.Env,
"id": p.ID, "createdAt": p.CreatedAt, "updatedAt": p.UpdatedAt,
"lastRun": func() *model.PipelineRun {
if hasRun {
return &lastRun
}
return nil
}(),
})
}
// GetSummary GET /api/summary —— 侧边栏徽标与首页统计
func GetSummary(c *gin.Context) {
var buildNodes, plugins, running, pipelines, hosts, clusters int64
database.DB.Model(&model.BuildNode{}).Count(&buildNodes)
database.DB.Model(&model.Plugin{}).Count(&plugins)
database.DB.Model(&model.PipelineRun{}).Where("status IN ('pending','queued','running')").Count(&running)
database.DB.Model(&model.Pipeline{}).Count(&pipelines)
database.DB.Model(&model.Host{}).Count(&hosts)
database.DB.Model(&model.K8sCluster{}).Count(&clusters)
c.JSON(http.StatusOK, gin.H{
"buildNodes": buildNodes, "plugins": plugins, "running": running,
"pipelines": pipelines, "hosts": hosts, "clusters": clusters,
})
}
// GetSettings GET /api/settings —— 单行设置
func GetSettings(c *gin.Context) {
var s model.SystemSetting
if err := database.DB.First(&s, 1).Error; err != nil {
s = model.SystemSetting{ID: 1}
database.DB.Create(&s)
}
c.JSON(http.StatusOK, s)
}
// UpdateSettings PUT /api/settings
func UpdateSettings(c *gin.Context) {
var s model.SystemSetting
if err := database.DB.First(&s, 1).Error; err != nil {
s = model.SystemSetting{ID: 1}
database.DB.Create(&s)
}
var body map[string]any
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]any{"updated_at": time.Now()}
if v, ok := body["theme"].(string); ok {
updates["theme"] = v
}
if v, ok := body["notification"].(string); ok {
updates["notification"] = v
}
if v, ok := body["toolMode"].(string); ok {
updates["tool_mode"] = v
}
if v, ok := body["extra"].(string); ok {
updates["extra"] = v
}
database.DB.Model(&s).Updates(updates)
c.JSON(http.StatusOK, s)
}
// CreatePipeline POST /api/pipelines
func CreatePipeline(c *gin.Context) {
var input struct {
@@ -82,7 +162,12 @@ func UpdatePipeline(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
allowed := map[string]bool{"name": true, "description": true, "sourceRepo": true, "sourceBranch": true, "gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true}
allowed := map[string]bool{
"name": true, "description": true, "sourceRepo": true, "sourceBranch": true,
"gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true,
"triggerMode": true, "cronExpr": true, "webhookToken": true, "watchBranches": true,
"variables": true, "cachePaths": true, "notifyWebhookURL": true, "jenkinsJobName": true,
}
updates := map[string]any{}
for k, v := range input {
if allowed[k] {
+755
View File
@@ -0,0 +1,755 @@
package handler
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http"
"os/exec"
"strings"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/model"
)
// dialTCP 真实连通性测试:TCP 拨号 5 秒超时
func dialTCP(addr string) error {
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
if err != nil {
return err
}
return conn.Close()
}
// ============================================================
// 主机 /api/hosts
// ============================================================
func ListHosts(c *gin.Context) {
var list []model.Host
q := database.DB.Order("updated_at DESC")
if tag := c.Query("tag"); tag != "" {
q = q.Where("tags LIKE ?", "%"+tag+"%")
}
q.Find(&list)
c.JSON(http.StatusOK, list)
}
func CreateHost(c *gin.Context) {
var h model.Host
if err := c.ShouldBindJSON(&h); err != nil || h.Name == "" || h.IP == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
return
}
h.ID = 0
if err := database.DB.Create(&h).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
c.JSON(http.StatusOK, h)
}
func UpdateHost(c *gin.Context) {
var h model.Host
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
return
}
var body map[string]any
_ = c.ShouldBindJSON(&body)
for _, k := range []string{"name", "ip", "port", "username", "tags", "os", "remark"} {
if v, ok := body[k]; ok {
database.DB.Model(&h).Update(k, v)
}
}
database.DB.First(&h, h.ID)
c.JSON(http.StatusOK, h)
}
func DeleteHost(c *gin.Context) {
database.DB.Delete(&model.Host{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestHost POST /api/hosts/:id/test —— 真实 TCP 拨号 SSH 端口
func TestHost(c *gin.Context) {
var h model.Host
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
return
}
start := time.Now()
err := dialTCP(fmt.Sprintf("%s:%d", h.IP, h.SSHPort))
latency := time.Since(start).Milliseconds()
status := "online"
if err != nil {
status = "offline"
}
database.DB.Model(&h).Update("status", status)
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "status": status, "latencyMs": latency,
"message": map[bool]string{true: fmt.Sprintf("SSH 端口可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
})
}
// ============================================================
// 集群 /api/clusters
// ============================================================
func ListClusters(c *gin.Context) {
var list []model.K8sCluster
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].KubeconfigSet = list[i].Kubeconfig != ""
list[i].Kubeconfig = ""
}
c.JSON(http.StatusOK, list)
}
func CreateCluster(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称不能为空"})
return
}
cl := model.K8sCluster{Name: name}
cl.Context, _ = raw["context"].(string)
cl.APIServer, _ = raw["apiServer"].(string)
cl.Version, _ = raw["version"].(string)
if v, ok := raw["nodes"].(float64); ok {
cl.NodeCount = int(v)
}
if v, ok := raw["namespaces"].(string); ok {
cl.Namespaces = v
} else if ns, ok := raw["namespaces"].([]any); ok {
if b, err := json.Marshal(ns); err == nil {
cl.Namespaces = string(b)
}
}
if kubeconfig, _ := raw["kubeconfig"].(string); kubeconfig != "" {
enc, err := crypto.EncryptField(kubeconfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
return
}
cl.Kubeconfig = enc
}
if err := database.DB.Create(&cl).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
cl.KubeconfigSet = cl.Kubeconfig != ""
cl.Kubeconfig = ""
c.JSON(http.StatusOK, cl)
}
func UpdateCluster(c *gin.Context) {
var cl model.K8sCluster
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]any{}
for _, k := range []string{"name", "context", "apiServer", "version", "nodes", "namespaces"} {
if v, ok := raw[k]; ok {
updates[k] = v
}
}
// kubeconfig 只写不读:非空才更新
if kubeconfig, ok := raw["kubeconfig"].(string); ok && kubeconfig != "" {
enc, err := crypto.EncryptField(kubeconfig)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
return
}
updates["kubeconfig"] = enc
}
if len(updates) > 0 {
database.DB.Model(&cl).Updates(updates)
}
database.DB.First(&cl, cl.ID)
cl.KubeconfigSet = cl.Kubeconfig != ""
cl.Kubeconfig = ""
c.JSON(http.StatusOK, cl)
}
func DeleteCluster(c *gin.Context) {
database.DB.Delete(&model.K8sCluster{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestCluster POST /api/clusters/:id/test —— 拨号 APIServer(或 kubeconfig 中的 server)
func TestCluster(c *gin.Context) {
var cl model.K8sCluster
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
target := cl.APIServer
if cl.Kubeconfig != "" && crypto.IsEncrypted(cl.Kubeconfig) {
if plain, err := crypto.DecryptField(cl.Kubeconfig); err == nil {
for _, line := range strings.Split(plain, "\n") {
if trimmed := strings.TrimSpace(line); strings.HasPrefix(trimmed, "server:") {
target = strings.TrimSpace(strings.TrimPrefix(trimmed, "server:"))
break
}
}
}
}
if target == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未配置 APIServer 或 kubeconfig"})
return
}
host := strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://")
err := dialTCP(host)
status := "connected"
if err != nil {
status = "disconnected"
}
now := time.Now()
database.DB.Model(&cl).Updates(map[string]any{"status": status, "last_heartbeat": &now})
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "status": status,
"message": map[bool]string{true: "APIServer 可达", false: "连接失败: " + err.Error()}[err == nil],
})
}
// ============================================================
// 凭证 /api/credentials(内容只写不读)
// ============================================================
func ListCredentials(c *gin.Context) {
var list []model.Credential
q := database.DB.Order("updated_at DESC")
if t := c.Query("type"); t != "" && t != "all" {
q = q.Where("type = ?", t)
}
q.Find(&list)
for i := range list {
list[i].ContentSet = list[i].Content != ""
list[i].Content = ""
}
c.JSON(http.StatusOK, list)
}
func CreateCredential(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
typ, _ := raw["type"].(string)
content, _ := raw["content"].(string)
if name == "" || typ == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和类型不能为空"})
return
}
cred := model.Credential{Name: name, Type: typ}
if scope, ok := raw["scope"].(string); ok {
cred.Scope = scope
}
if content != "" {
enc, err := crypto.EncryptField(content)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
cred.Content = enc
cred.Summary = summarizeCredential(typ, content)
}
if err := database.DB.Create(&cred).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
cred.ContentSet = cred.Content != ""
cred.Content = ""
c.JSON(http.StatusOK, cred)
}
// summarizeCredential 从明文 JSON 中提取脱敏摘要(不落明文)
func summarizeCredential(typ, contentJSON string) string {
var m map[string]any
if err := json.Unmarshal([]byte(contentJSON), &m); err != nil {
return typ + " 凭证"
}
pick := func(keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return k + "=" + crypto.Mask(v)
}
}
return ""
}
switch typ {
case "ssh":
return typ + " " + pick("ssh_user", "username", "private_key")
case "docker":
return typ + " " + pick("server") + " " + pick("username")
case "git":
return typ + " " + pick("url", "username")
case "kubeconfig":
return typ + " (kubeconfig)"
case "aksk":
return typ + " " + pick("access_key")
default:
if len(m) > 0 {
return typ + " (" + fmt.Sprint(len(m)) + " 项)"
}
return typ + " 凭证"
}
}
func UpdateCredential(c *gin.Context) {
var cred model.Credential
if err := database.DB.First(&cred, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
return
}
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
updates := map[string]any{}
if v, ok := raw["name"].(string); ok && v != "" {
updates["name"] = v
}
if v, ok := raw["scope"].(string); ok {
updates["scope"] = v
}
if content, ok := raw["content"].(string); ok && content != "" {
enc, err := crypto.EncryptField(content)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
updates["content"] = enc
typ, _ := raw["type"].(string)
if typ == "" {
typ = cred.Type
}
updates["summary"] = summarizeCredential(typ, content)
}
if len(updates) > 0 {
database.DB.Model(&cred).Updates(updates)
}
database.DB.First(&cred, cred.ID)
cred.ContentSet = cred.Content != ""
cred.Content = ""
c.JSON(http.StatusOK, cred)
}
func DeleteCredential(c *gin.Context) {
database.DB.Delete(&model.Credential{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// ============================================================
// 构建节点 /api/build-nodes
// ============================================================
func ListBuildNodes(c *gin.Context) {
var list []model.BuildNode
database.DB.Order("updated_at DESC").Find(&list)
c.JSON(http.StatusOK, list)
}
func CreateBuildNode(c *gin.Context) {
var n model.BuildNode
if err := c.ShouldBindJSON(&n); err != nil || n.Name == "" || n.IP == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
return
}
n.ID = 0
if err := database.DB.Create(&n).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
return
}
c.JSON(http.StatusOK, n)
}
func UpdateBuildNode(c *gin.Context) {
var n model.BuildNode
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
for _, k := range []string{"name", "ip", "labels", "maxConcurrency", "workspacePath", "enabled"} {
if v, ok := raw[k]; ok {
database.DB.Model(&n).Update(k, v)
}
}
database.DB.First(&n, n.ID)
c.JSON(http.StatusOK, n)
}
func DeleteBuildNode(c *gin.Context) {
database.DB.Delete(&model.BuildNode{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestBuildNode POST /api/build-nodes/:id/test —— 真实 TCP 拨号
func TestBuildNode(c *gin.Context) {
var n model.BuildNode
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
return
}
start := time.Now()
err := dialTCP(n.IP + ":22")
latency := time.Since(start).Milliseconds()
status := "online"
if err != nil {
status = "offline"
}
now := time.Now()
database.DB.Model(&n).Updates(map[string]any{"status": status, "last_heartbeat": &now})
c.JSON(http.StatusOK, gin.H{
"success": err == nil, "status": status, "latencyMs": latency,
"message": map[bool]string{true: fmt.Sprintf("节点可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
})
}
// ============================================================
// 工具 /api/tools(真实检测 + 保守安装)
// ============================================================
func ListTools(c *gin.Context) {
var list []model.DeployTool
database.DB.Order("sort_order").Find(&list)
c.JSON(http.StatusOK, list)
}
func UpdateTool(c *gin.Context) {
var t model.DeployTool
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
for _, k := range []string{"installMode", "version", "path", "enabled"} {
if v, ok := raw[k]; ok {
database.DB.Model(&t).Update(k, v)
}
}
database.DB.First(&t, t.ID)
c.JSON(http.StatusOK, t)
}
// runToolCheck 真实执行 CheckCommand,返回输出中的版本号片段
func runToolCheck(checkCommand string) (version string, out string, err error) {
cmd := exec.Command("sh", "-c", checkCommand)
cmd.Stderr = nil
bytes, err := cmd.Output()
out = strings.TrimSpace(string(bytes))
if err != nil {
// java -version 等把版本打到 stderr
if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
out = strings.TrimSpace(string(exitErr.Stderr))
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "version") {
return extractVersion(line), out, nil
}
}
}
return "", out, err
}
return extractVersion(out), out, nil
}
// extractVersion 从命令输出中提取首个 x.y.z 形态的版本号
func extractVersion(s string) string {
fields := strings.Fields(s)
inVersion := false
for i, f := range fields {
if strings.Contains(f, "version") {
if i+1 < len(fields) {
return strings.Trim(fields[i+1], `"`)
}
inVersion = true
continue
}
if inVersion {
return strings.Trim(f, `"`)
}
}
// 兜底:找 x.y.z
for _, f := range fields {
if strings.Count(f, ".") >= 1 && f[0] >= '0' && f[0] <= '9' {
return strings.Trim(f, `"`)
}
}
return ""
}
// CheckTool POST /api/tools/:id/check —— 真实执行检测命令并回填版本
func CheckTool(c *gin.Context) {
var t model.DeployTool
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
return
}
version, out, err := runToolCheck(t.CheckCommand)
now := time.Now()
updates := map[string]any{"last_check": &now}
if err == nil && version != "" {
updates["status"] = "installed"
updates["installed_version"] = version
updates["error_msg"] = ""
} else {
updates["status"] = "not-installed"
updates["error_msg"] = "未检测到:" + firstLine(out, err)
}
database.DB.Model(&t).Updates(updates)
database.DB.First(&t, t.ID)
c.JSON(http.StatusOK, gin.H{"tool": t, "output": out, "detected": version})
}
// InstallTool POST /api/tools/:id/install —— 保守安装:真实检测,检出即装;未检出标记 failed 提示手动安装
func InstallTool(c *gin.Context) {
var t model.DeployTool
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
return
}
database.DB.Model(&t).Update("status", "installing")
version, out, err := runToolCheck(t.CheckCommand)
now := time.Now()
updates := map[string]any{"last_check": &now}
if err == nil && version != "" {
updates["status"] = "installed"
updates["installed_version"] = version
updates["error_msg"] = ""
c.JSON(http.StatusOK, gin.H{"success": true, "message": "检测到已安装 " + version, "version": version, "output": out})
} else {
updates["status"] = "failed"
updates["error_msg"] = "本机未检测到 " + t.DisplayName + ",保守安装策略不会自动下载,请手动安装(期望版本 " + t.Version + ")"
c.JSON(http.StatusOK, gin.H{"success": false, "message": updates["error_msg"], "output": firstLine(out, err)})
}
database.DB.Model(&t).Updates(updates)
}
func firstLine(out string, err error) string {
if err != nil {
return "命令执行失败: " + err.Error()
}
for _, l := range strings.Split(out, "\n") {
if l != "" {
return l
}
}
return out
}
// ============================================================
// Webhook /api/webhooks(HMAC-SHA256 签名)
// ============================================================
func ListWebhooks(c *gin.Context) {
var list []model.WebhookEndpoint
database.DB.Order("updated_at DESC").Find(&list)
for i := range list {
list[i].SecretSet = list[i].Secret != ""
list[i].Secret = ""
}
c.JSON(http.StatusOK, list)
}
func CreateWebhook(c *gin.Context) {
var raw map[string]any
if err := c.ShouldBindJSON(&raw); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
name, _ := raw["name"].(string)
url, _ := raw["url"].(string)
if name == "" || url == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 URL 不能为空"})
return
}
wh := model.WebhookEndpoint{Name: name, URL: url, Active: true}
if events, ok := raw["events"]; ok {
if b, err := json.Marshal(events); err == nil {
wh.Events = string(b)
}
}
if secret, ok := raw["secret"].(string); ok && secret != "" {
enc, err := crypto.EncryptField(secret)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
return
}
wh.Secret = enc
}
if err := database.DB.Create(&wh).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
wh.SecretSet = wh.Secret != ""
wh.Secret = ""
c.JSON(http.StatusOK, wh)
}
func UpdateWebhook(c *gin.Context) {
var wh model.WebhookEndpoint
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
return
}
var raw map[string]any
_ = c.ShouldBindJSON(&raw)
updates := map[string]any{}
if v, ok := raw["name"].(string); ok {
updates["name"] = v
}
if v, ok := raw["url"].(string); ok {
updates["url"] = v
}
if v, ok := raw["active"].(bool); ok {
updates["active"] = v
}
if events, ok := raw["events"]; ok {
if b, err := json.Marshal(events); err == nil {
updates["events"] = string(b)
}
}
if secret, ok := raw["secret"].(string); ok && secret != "" {
if enc, err := crypto.EncryptField(secret); err == nil {
updates["secret"] = enc
}
}
if len(updates) > 0 {
database.DB.Model(&wh).Updates(updates)
}
database.DB.First(&wh, wh.ID)
wh.SecretSet = wh.Secret != ""
wh.Secret = ""
c.JSON(http.StatusOK, wh)
}
func DeleteWebhook(c *gin.Context) {
database.DB.Delete(&model.WebhookEndpoint{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
// TestWebhook POST /api/webhooks/:id/test —— 真实 HTTP POST + HMAC-SHA256 签名
func TestWebhook(c *gin.Context) {
var wh model.WebhookEndpoint
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
return
}
payload, _ := json.Marshal(map[string]any{
"event": "webhook.test", "timestamp": time.Now().Format(time.RFC3339),
"message": "FlowStack Webhook 测试消息",
})
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FlowStack-Event", "webhook.test")
if wh.Secret != "" {
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
now := time.Now()
if err != nil {
database.DB.Model(&wh).Updates(map[string]any{"last_status": "failed", "last_triggered_at": &now})
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败: " + err.Error()})
return
}
defer resp.Body.Close()
status := "success"
if resp.StatusCode >= 300 {
status = "failed"
}
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
c.JSON(http.StatusOK, gin.H{"success": resp.StatusCode < 300, "statusCode": resp.StatusCode, "message": fmt.Sprintf("响应 %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))})
}
// NotifyWebhookEvent 流水线事件发生后通知订阅了该事件的 Webhook(异步调用)
func NotifyWebhookEvent(event string, data map[string]any) {
go func() {
var endpoints []model.WebhookEndpoint
database.DB.Where("active = ?", true).Find(&endpoints)
payload, _ := json.Marshal(map[string]any{"event": event, "timestamp": time.Now().Format(time.RFC3339), "data": data})
client := &http.Client{Timeout: 10 * time.Second}
for _, wh := range endpoints {
if !strings.Contains(wh.Events, event) {
continue
}
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-FlowStack-Event", event)
if wh.Secret != "" {
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
}
status := "success"
if resp, err := client.Do(req); err != nil || resp.StatusCode >= 300 {
status = "failed"
if resp != nil {
resp.Body.Close()
}
}
now := time.Now()
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
}
}()
}
// InboundPipelineHook POST /api/hooks/pipeline/:id?token= —— 入站触发(TriggerPanel 的 Webhook URL)
func InboundPipelineHook(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
var p model.Pipeline
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
token := c.Query("token")
if p.WebhookToken != "" && token != p.WebhookToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token 校验失败"})
return
}
if p.TriggerMode != "webhook" && p.TriggerMode != "git_push" {
c.JSON(http.StatusConflict, gin.H{"error": "流水线未启用 Webhook 触发"})
return
}
go triggerPipelineRun(hub, p, "", "", "webhook", "webhook")
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已触发流水线 " + p.Name})
}
}
+96 -46
View File
@@ -13,7 +13,7 @@ import (
"flowstack/internal/model"
)
// CreateRun 创建运行并启动模拟执行引擎
// CreateRun POST /api/pipelines/:id/runs —— 创建运行并启动执行引擎
func CreateRun(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
var p model.Pipeline
@@ -21,61 +21,82 @@ func CreateRun(hub *Hub) gin.HandlerFunc {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
var body struct {
Branch string `json:"branch"`
Env string `json:"env"`
TriggerBy string `json:"triggerBy"`
Branch string `json:"branch"`
Env string `json:"env"`
TriggerBy string `json:"triggerBy"`
TriggerType string `json:"triggerType"`
}
_ = c.ShouldBindJSON(&body)
if body.Branch == "" {
body.Branch = p.SourceBranch
}
if body.Env == "" {
body.Env = p.Env
}
if body.TriggerBy == "" {
body.TriggerBy = "admin"
}
var nodes []model.PipelineNode
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
if len(nodes) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "流水线没有节点,请先在画布中编排并保存"})
run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, body.TriggerBy, body.TriggerType)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var edges []model.PipelineEdge
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
nodesJSON, _ := json.Marshal(nodes)
edgesJSON, _ := json.Marshal(edges)
run := model.PipelineRun{
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
PipelineID: p.ID, PipelineName: p.Name,
Branch: body.Branch, Env: body.Env, DeployType: p.DeployType, Type: p.Type,
TriggerBy: body.TriggerBy, TriggerType: "manual",
Status: "running", StartedAt: time.Now(),
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
}
if err := database.DB.Create(&run).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 按画布 X 坐标从左到右生成执行阶段
sort.Slice(nodes, func(i, j int) bool { return nodes[i].PositionX < nodes[j].PositionX })
stages := make([]model.RunStage, len(nodes))
for i, n := range nodes {
stages[i] = model.RunStage{RunID: run.ID, NodeID: n.NodeID, Name: n.Name, Status: "pending"}
database.DB.Create(&stages[i])
}
go simulateRun(hub, run, stages)
c.JSON(http.StatusOK, run)
}
}
// triggerPipelineRun 统一的运行创建入口:校验节点 → 建 Run/Stages → 启动执行引擎
func triggerPipelineRun(hub *Hub, p model.Pipeline, branch, env, triggerBy, triggerType string) (*model.PipelineRun, error) {
if branch == "" {
branch = p.SourceBranch
}
if env == "" {
env = p.Env
}
if triggerBy == "" {
triggerBy = "admin"
}
if triggerType == "" {
triggerType = "manual"
}
var nodes []model.PipelineNode
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
if len(nodes) == 0 {
return nil, fmt.Errorf("流水线没有节点,请先在画布中编排并保存")
}
var edges []model.PipelineEdge
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
nodesJSON, _ := json.Marshal(nodes)
edgesJSON, _ := json.Marshal(edges)
run := model.PipelineRun{
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
PipelineID: p.ID, PipelineName: p.Name,
Branch: branch, Env: env, DeployType: p.DeployType, Type: p.Type,
TriggerBy: triggerBy, TriggerType: triggerType,
Status: "running", StartedAt: time.Now(),
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
}
if err := database.DB.Create(&run).Error; err != nil {
return nil, err
}
// jenkins 类型流水线走真实 Jenkins 执行引擎
if p.Type == "jenkins" && p.JenkinsJobName != "" {
var server model.JenkinsServer
if err := database.DB.First(&server, p.JenkinsServerID).Error; err == nil && server.Enabled {
client := clientFromServer(server)
go jenkinsRunEngine(hub, run, client, p.JenkinsJobName)
return &run, nil
}
}
// 按画布 X 坐标从左到右生成执行阶段
sort.Slice(nodes, func(i, j int) bool { return nodes[i].PositionX < nodes[j].PositionX })
stages := make([]model.RunStage, len(nodes))
for i, n := range nodes {
stages[i] = model.RunStage{RunID: run.ID, NodeID: n.NodeID, Name: n.Name, Status: "pending"}
database.DB.Create(&stages[i])
}
go simulateRun(hub, run, stages)
return &run, nil
}
// simulateRun 模拟执行:逐阶段 pending→running→success,逐条写日志并 SSE 推送
func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
start := time.Now()
@@ -106,6 +127,13 @@ func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook
if status == "success" || status == "failed" {
NotifyWebhookEvent("pipeline."+status, map[string]any{
"runId": run.RunID, "pipeline": run.PipelineName,
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
})
}
}
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
@@ -270,3 +298,25 @@ func StreamLogs(hub *Hub) gin.HandlerFunc {
}
}
}
// RetryRun POST /api/runs/:runId/retry —— 以同一流水线与分支重新触发
func RetryRun(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
var prev model.PipelineRun
if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&prev).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
return
}
var p model.Pipeline
if err := database.DB.First(&p, prev.PipelineID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, "admin", "manual")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, run)
}
}