后端(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 数据文件
245 lines
7.2 KiB
Go
245 lines
7.2 KiB
Go
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))
|
|
}
|
|
|