Files
aiops/backend/controller/pipeline_jenkins.go
T
Logan.Li fd60361d93 chore: 统一本地服务器与远程仓库源码
- 合并 main 与 master 历史并以服务器结构为基线
- 保留迁移、数据保留、AWS 账单与部署安全能力
- 建立 SDD 规格和 CI 验证闭环
2026-08-05 20:03:25 +08:00

1363 lines
41 KiB
Go

package controller
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"os/exec"
"regexp"
"sort"
"strconv"
"strings"
"time"
"ezops/model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const jenkinsPollInterval = 5 * time.Second
type jenkinsClient struct {
baseURL string
username string
token string
client *http.Client
crumb map[string]string
}
type jenkinsJob struct {
Name string `json:"name"`
URL string `json:"url"`
Color string `json:"color"`
Class string `json:"_class"`
Description string `json:"description"`
Jobs []jenkinsJob `json:"jobs"`
Properties []struct {
ParameterDefinitions []jenkinsParameterDefinition `json:"parameterDefinitions"`
} `json:"property"`
ViewName string `json:"-"`
ViewURL string `json:"-"`
Views []jenkinsJobView `json:"-"`
LastBuild *struct {
Number int `json:"number"`
URL string `json:"url"`
Timestamp int64 `json:"timestamp"`
Duration int64 `json:"duration"`
Building bool `json:"building"`
Result string `json:"result"`
} `json:"lastBuild"`
}
type jenkinsJobView struct {
Name string `json:"name"`
URL string `json:"url"`
}
type jenkinsParameterDefinition struct {
Class string `json:"_class,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Choices []string `json:"choices,omitempty"`
Default interface{} `json:"default,omitempty"`
DefaultParameterValue *struct {
Value interface{} `json:"value"`
} `json:"defaultParameterValue,omitempty"`
}
type jenkinsJobTree struct {
Name string `json:"name"`
URL string `json:"url"`
Color string `json:"color"`
Class string `json:"_class"`
Description string `json:"description"`
Jobs []jenkinsJob `json:"jobs"`
Properties []struct {
ParameterDefinitions []jenkinsParameterDefinition `json:"parameterDefinitions"`
} `json:"property"`
Views []jenkinsView `json:"views"`
LastBuild *struct {
Number int `json:"number"`
URL string `json:"url"`
Timestamp int64 `json:"timestamp"`
Duration int64 `json:"duration"`
Building bool `json:"building"`
Result string `json:"result"`
} `json:"lastBuild"`
}
type jenkinsView struct {
Name string `json:"name"`
URL string `json:"url"`
Jobs []jenkinsJob `json:"jobs"`
}
type jenkinsQueueItem struct {
ID int `json:"id"`
Cancelled bool `json:"cancelled"`
Why string `json:"why"`
Executable *struct {
Number int `json:"number"`
URL string `json:"url"`
} `json:"executable"`
}
type jenkinsBuildInfo struct {
Building bool `json:"building"`
Result string `json:"result"`
Number int `json:"number"`
URL string `json:"url"`
Timestamp int64 `json:"timestamp"`
Duration int64 `json:"duration"`
}
type jenkinsJobMetadata struct {
Source string `json:"source,omitempty"`
DefaultBranch string `json:"default_branch,omitempty"`
ShellSteps []string `json:"shell_steps,omitempty"`
Parameters []jenkinsParameterDefinition
}
func normalizeCIProvider(provider string) string {
if strings.EqualFold(provider, "jenkins") {
return "jenkins"
}
return "aiops"
}
func normalizeJenkinsRefMode(mode string) string {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "branch", "tag", "both":
return strings.ToLower(strings.TrimSpace(mode))
default:
return "both"
}
}
func normalizeJenkinsRefParam(param string) string {
param = strings.TrimSpace(param)
if param == "" {
return "SELECT"
}
return param
}
func (h *PipelineHandler) getJenkinsConfig() (model.JenkinsConfig, string, error) {
var cfg model.JenkinsConfig
if err := h.db.Order("id ASC").First(&cfg).Error; err != nil {
return cfg, "", err
}
token := ""
if cfg.Token != "" {
plain, err := h.crypt.Decrypt(cfg.Token)
if err != nil {
return cfg, "", err
}
token = plain
}
return cfg, token, nil
}
func (h *PipelineHandler) jenkinsConfigPayload(cfg model.JenkinsConfig) gin.H {
return gin.H{
"id": cfg.ID,
"base_url": cfg.BaseURL,
"username": cfg.Username,
"auth_type": cfg.AuthType,
"enabled": cfg.Enabled,
"ref_mode": normalizeJenkinsRefMode(cfg.RefMode),
"ref_param": normalizeJenkinsRefParam(cfg.RefParam),
"token_set": cfg.Token != "",
"last_sync_at": cfg.LastSyncAt,
"created_at": cfg.CreatedAt,
"updated_at": cfg.UpdatedAt,
}
}
// GetJenkinsConfig 获取 Jenkins 接入配置,密钥不回显。 // Logan.Li
func (h *PipelineHandler) GetJenkinsConfig(c *gin.Context) {
cfg, _, err := h.getJenkinsConfig()
if err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"enabled": false, "token_set": false}})
return
}
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取 Jenkins 配置失败"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: h.jenkinsConfigPayload(cfg)})
}
// SaveJenkinsConfig 保存 Jenkins 接入配置,Token 为空时保留旧值。 // Logan.Li
func (h *PipelineHandler) SaveJenkinsConfig(c *gin.Context) {
var req model.JenkinsConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
return
}
baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
if _, err := url.ParseRequestURI(baseURL); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 地址格式不正确"})
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
authType := req.AuthType
if authType == "" {
authType = "token"
}
var cfg model.JenkinsConfig
err := h.db.Order("id ASC").First(&cfg).Error
if err != nil && err != gorm.ErrRecordNotFound {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "读取 Jenkins 配置失败"})
return
}
tokenCipher := cfg.Token
if req.Token != "" {
enc, encErr := h.crypt.Encrypt(req.Token)
if encErr != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "加密 Jenkins Token 失败"})
return
}
tokenCipher = enc
}
cfg.BaseURL = baseURL
cfg.Username = strings.TrimSpace(req.Username)
cfg.Token = tokenCipher
cfg.AuthType = authType
cfg.Enabled = enabled
cfg.RefMode = normalizeJenkinsRefMode(req.RefMode)
cfg.RefParam = normalizeJenkinsRefParam(req.RefParam)
if cfg.ID == 0 {
err = h.db.Create(&cfg).Error
} else {
err = h.db.Save(&cfg).Error
}
if err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "保存 Jenkins 配置失败"})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "保存成功", Data: h.jenkinsConfigPayload(cfg)})
}
// TestJenkinsConfig 测试 Jenkins 连接。 // Logan.Li
func (h *PipelineHandler) TestJenkinsConfig(c *gin.Context) {
cfg, token, err := h.getJenkinsConfig()
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请先保存 Jenkins 配置"})
return
}
client := newJenkinsClient(cfg.BaseURL, cfg.Username, token)
if err := client.ensureCrumb(c.Request.Context()); err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 连接失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "连接成功", Data: gin.H{"base_url": cfg.BaseURL, "crumb": client.crumb != nil}})
}
// SyncJenkinsJobs 扫描 Jenkins Jobs 并同步成 AIOps 流水线。 // Logan.Li
func (h *PipelineHandler) SyncJenkinsJobs(c *gin.Context) {
var req model.JenkinsSyncRequest
_ = c.ShouldBindJSON(&req)
cfg, token, err := h.getJenkinsConfig()
if err != nil || !cfg.Enabled {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 未配置或未启用"})
return
}
client := newJenkinsClient(cfg.BaseURL, cfg.Username, token)
jobs, err := client.listJobs(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "同步 Jenkins Jobs 失败: " + err.Error()})
return
}
created, updated, failed := 0, 0, 0
for _, job := range jobs {
meta := client.jobMetadata(c.Request.Context(), job.URL)
jobViewsJSON := encodeJenkinsJobViews(job.Views)
jobParamsJSON := encodeJenkinsParameters(mergeJenkinsParameters(job.Parameters(), meta.Parameters))
jobStepsJSON := encodeJenkinsSteps(meta.ShellSteps)
jobLastRunAt := jenkinsLastBuildTime(job)
jobBranch := meta.DefaultBranch
if jobBranch == "" {
jobBranch = "main"
}
code := pipelineCodeFromJenkinsJob(req.Prefix, job.Name)
uniqueCode := h.uniquePipelineCode(code)
var p model.Pipeline
err := h.db.Where("ci_provider = ? AND (jenkins_job_url = ? OR jenkins_job_name = ?)", "jenkins", job.URL, job.Name).First(&p).Error
if err == gorm.ErrRecordNotFound {
p = model.Pipeline{
Name: job.Name,
Code: uniqueCode,
JobName: uniqueCode,
Description: job.Description,
Type: "build",
CIProvider: "jenkins",
Source: meta.Source,
Branch: jobBranch,
DeployMode: "tag",
DeployTarget: "ssh",
TriggerMode: "manual",
MaxExecutors: 1,
Enabled: true,
JenkinsJobName: job.Name,
JenkinsJobURL: job.URL,
JenkinsView: job.ViewName,
JenkinsViewURL: job.ViewURL,
JenkinsViews: jobViewsJSON,
JenkinsParams: jobParamsJSON,
JenkinsSteps: jobStepsJSON,
LastRunAt: jobLastRunAt,
LastStatus: jenkinsColorToStatus(job.Color),
}
if err := h.db.Create(&p).Error; err == nil {
created++
} else {
failed++
}
continue
}
if err != nil {
failed++
continue
}
if req.Overwrite {
p.Name = job.Name
p.Description = job.Description
}
p.CIProvider = "jenkins"
p.JenkinsJobName = job.Name
p.JenkinsJobURL = job.URL
p.JenkinsView = job.ViewName
p.JenkinsViewURL = job.ViewURL
p.JenkinsViews = jobViewsJSON
p.JenkinsParams = jobParamsJSON
p.JenkinsSteps = jobStepsJSON
if meta.Source != "" {
p.Source = meta.Source
}
if meta.DefaultBranch != "" {
p.Branch = meta.DefaultBranch
}
if jobLastRunAt != nil {
p.LastRunAt = jobLastRunAt
}
p.LastStatus = jenkinsColorToStatus(job.Color)
if err := h.db.Save(&p).Error; err == nil {
updated++
} else {
failed++
}
}
now := time.Now()
_ = h.db.Model(&model.JenkinsConfig{}).Where("id = ?", cfg.ID).Update("last_sync_at", &now).Error
message := "同步完成"
if len(jobs) == 0 {
message = "同步完成,但未发现 Jenkins Jobs"
}
c.JSON(http.StatusOK, model.Response{Code: 200, Message: message, Data: gin.H{"created": created, "updated": updated, "failed": failed, "total": len(jobs), "views": countJenkinsViews(jobs)}})
}
// GetJenkinsRefs 只读获取 Jenkins Job 关联 Git 仓库的分支或 Tag 候选,不修改 Jenkins 配置。 // Logan.Li
func (h *PipelineHandler) GetJenkinsRefs(c *gin.Context) {
code := strings.TrimSpace(c.Query("code"))
refType := strings.ToLower(strings.TrimSpace(c.DefaultQuery("type", "branch")))
if refType != "tag" {
refType = "branch"
}
var pipeline model.Pipeline
q := h.db.Where("ci_provider = ?", "jenkins")
if code != "" {
q = q.Where("code = ? OR job_name = ? OR jenkins_job_name = ?", code, code, code)
} else if id, err := strconv.ParseUint(c.Query("pipeline_id"), 10, 64); err == nil && id > 0 {
q = q.Where("id = ?", id)
} else {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "缺少流水线编码"})
return
}
if err := q.First(&pipeline).Error; err != nil {
c.JSON(http.StatusNotFound, model.Response{Code: 404, Message: "Jenkins 流水线不存在"})
return
}
source := strings.TrimSpace(pipeline.Source)
if source == "" {
if cfg, token, err := h.getJenkinsConfig(); err == nil && cfg.Enabled {
client := newJenkinsClient(cfg.BaseURL, cfg.Username, token)
meta := client.jobMetadata(c.Request.Context(), pipeline.JenkinsJobURL)
source = meta.Source
if source != "" || meta.DefaultBranch != "" || len(meta.ShellSteps) > 0 {
updates := map[string]interface{}{}
if source != "" {
updates["source"] = source
}
if meta.DefaultBranch != "" {
updates["branch"] = meta.DefaultBranch
}
if len(meta.ShellSteps) > 0 {
updates["jenkins_steps"] = encodeJenkinsSteps(meta.ShellSteps)
}
_ = h.db.Model(&model.Pipeline{}).Where("id = ?", pipeline.ID).Updates(updates).Error
}
}
}
choices := jenkinsStaticRefChoices(pipeline, refType)
if source != "" {
refs, err := listGitRefs(c.Request.Context(), source, refType)
if err == nil && len(refs) > 0 {
choices = refs
}
}
c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{"source": source, "type": refType, "refs": choices}})
}
func (h *PipelineHandler) triggerJenkinsPipeline(c *gin.Context, pipeline model.Pipeline, req model.TriggerPipelineRequest) {
cfg, token, err := h.getJenkinsConfig()
if err != nil || !cfg.Enabled {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "Jenkins 未配置或未启用"})
return
}
var maxBuildNumber int
h.db.Model(&model.PipelineRun{}).Where("pipeline_id = ?", pipeline.ID).
Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber)
ref := req.Tag
if ref == "" {
ref = req.Branch
}
if ref == "" {
ref = pipeline.Branch
}
refMode := normalizeJenkinsRefMode(cfg.RefMode)
if refMode == "tag" && strings.TrimSpace(req.Tag) == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该 Jenkins 模式要求选择 Tag"})
return
}
if refMode == "branch" && strings.TrimSpace(req.Branch) == "" {
c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "该 Jenkins 模式要求选择分支"})
return
}
username, _ := c.Get("username")
userStr, _ := username.(string)
now := time.Now()
run := model.PipelineRun{
PipelineID: pipeline.ID,
BuildNumber: maxBuildNumber + 1,
Status: "running",
Branch: ref,
ImageTag: req.Tag,
TriggeredBy: userStr,
StartedAt: &now,
ExternalURL: pipeline.JenkinsJobURL,
BuildNodeName: "Jenkins",
}
if err := h.db.Create(&run).Error; err != nil {
c.JSON(http.StatusInternalServerError, model.Response{Code: 500, Message: "触发失败"})
return
}
client := newJenkinsClient(cfg.BaseURL, cfg.Username, token)
params := map[string]string{}
for k, v := range req.Variables {
params[k] = v
}
if ref != "" {
params[preferredJenkinsRefParam(pipeline, cfg.RefParam)] = ref
}
go h.executeJenkinsRun(c.Request.Context(), client, pipeline, run, params)
c.JSON(http.StatusOK, model.Response{Code: 200, Message: "已触发 Jenkins", Data: run})
}
func (h *PipelineHandler) executeJenkinsRun(parent context.Context, client *jenkinsClient, pipeline model.Pipeline, run model.PipelineRun, params map[string]string) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour)
defer cancel()
logKey := fmt.Sprintf("pipeline:run:%d:logs", run.ID)
logFn := h.jenkinsLogFn(ctx, logKey)
start := time.Now()
logFn("info", "Jenkins", ">>> 触发 Jenkins Job: "+pipeline.JenkinsJobName)
queueURL, err := client.trigger(ctx, pipeline, params)
if err != nil {
h.finishJenkinsRun(run, pipeline, "failed", start, "", "", logKey, fmt.Sprintf("触发失败: %v", err))
return
}
_ = parent
logFn("info", "Jenkins", ">>> Jenkins 队列: "+queueURL)
build, err := client.waitQueue(ctx, queueURL, logFn)
if err != nil {
h.finishJenkinsRun(run, pipeline, "failed", start, "", queueURL, logKey, fmt.Sprintf("获取构建号失败: %v", err))
return
}
buildNumber := strconv.Itoa(build.Number)
_ = h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]interface{}{
"external_run_id": buildNumber,
"external_url": build.URL,
"build_number": build.Number,
}).Error
logFn("info", "Jenkins", fmt.Sprintf(">>> Jenkins Build #%d: %s", build.Number, build.URL))
final, err := client.waitBuild(ctx, build.URL, logFn)
if err != nil {
h.finishJenkinsRun(run, pipeline, "failed", start, buildNumber, build.URL, logKey, fmt.Sprintf("轮询失败: %v", err))
return
}
status := "failed"
if final.Result == "SUCCESS" {
status = "success"
} else if final.Result == "ABORTED" {
status = "cancelled"
}
h.finishJenkinsRun(run, pipeline, status, start, buildNumber, final.URL, logKey, "Jenkins result: "+final.Result)
}
func (h *PipelineHandler) jenkinsLogFn(ctx context.Context, logKey string) func(string, string, string) {
return func(level, stage, msg string) {
entry := LogEntry{Time: time.Now().Format("15:04:05"), Level: level, Stage: stage, Message: msg}
if h.cache == nil {
return
}
existing, _ := h.cache.Get(ctx, logKey)
var entries []LogEntry
if existing != "" {
_ = json.Unmarshal([]byte(existing), &entries)
}
entries = append(entries, entry)
buf, _ := json.Marshal(entries)
_ = h.cache.Set(ctx, logKey, string(buf), 30*time.Minute)
}
}
func (h *PipelineHandler) finishJenkinsRun(run model.PipelineRun, pipeline model.Pipeline, status string, start time.Time, externalID, externalURL, logKey, message string) {
now := time.Now()
duration := int(now.Sub(start).Seconds())
logFn := h.jenkinsLogFn(context.Background(), logKey)
level := "info"
if status == "failed" {
level = "error"
}
logFn(level, "Jenkins", ">>> "+message)
logs, _ := h.cache.Get(context.Background(), logKey)
updates := map[string]interface{}{
"status": status,
"duration": duration,
"finished_at": &now,
"logs": logs,
}
if externalID != "" {
updates["external_run_id"] = externalID
}
if externalURL != "" {
updates["external_url"] = externalURL
}
_ = h.db.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates).Error
pUpdates := map[string]interface{}{"last_status": status, "last_run_at": now, "run_count": gorm.Expr("run_count + ?", 1)}
if status == "success" {
pUpdates["success_count"] = gorm.Expr("success_count + ?", 1)
} else if status == "failed" {
pUpdates["fail_count"] = gorm.Expr("fail_count + ?", 1)
}
_ = h.db.Model(&model.Pipeline{}).Where("id = ?", pipeline.ID).Updates(pUpdates).Error
}
func newJenkinsClient(baseURL, username, token string) *jenkinsClient {
return &jenkinsClient{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
token: token,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (c *jenkinsClient) request(ctx context.Context, method, rawURL string, form url.Values) (*http.Response, error) {
var body *strings.Reader
if form != nil {
body = strings.NewReader(form.Encode())
} else {
body = strings.NewReader("")
}
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, err
}
if c.username != "" || c.token != "" {
req.SetBasicAuth(c.username, c.token)
}
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
for k, v := range c.crumb {
req.Header.Set(k, v)
}
return c.client.Do(req)
}
func (c *jenkinsClient) ensureCrumb(ctx context.Context) error {
resp, err := c.request(ctx, http.MethodGet, c.baseURL+"/crumbIssuer/api/json", nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusForbidden {
return nil
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("crumb status %d", resp.StatusCode)
}
var data struct {
Field string `json:"crumbRequestField"`
Crumb string `json:"crumb"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return err
}
if data.Field != "" && data.Crumb != "" {
c.crumb = map[string]string{data.Field: data.Crumb}
}
return nil
}
func (c *jenkinsClient) listJobs(ctx context.Context) ([]jenkinsJob, error) {
if err := c.ensureCrumb(ctx); err != nil {
return nil, err
}
visited := map[string]bool{}
return c.listJobsAt(ctx, c.baseURL, 0, visited)
}
func (c *jenkinsClient) listJobsAt(ctx context.Context, rootURL string, depth int, visited map[string]bool) ([]jenkinsJob, error) {
if depth > 8 {
return nil, nil
}
apiURL := strings.TrimRight(rootURL, "/") + "/api/json?tree=" + url.QueryEscape("name,url,color,description,_class,property[parameterDefinitions[_class,name,type,description,choices,defaultParameterValue[value]]],lastBuild[number,url,timestamp,duration,building,result],jobs[name,url,color,description,_class,property[parameterDefinitions[_class,name,type,description,choices,defaultParameterValue[value]]],lastBuild[number,url,timestamp,duration,building,result],jobs[name,url]],views[name,url,jobs[name,url,color,description,_class,property[parameterDefinitions[_class,name,type,description,choices,defaultParameterValue[value]]],lastBuild[number,url,timestamp,duration,building,result]]]")
resp, err := c.request(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("jobs api status %d", resp.StatusCode)
}
var root jenkinsJobTree
if err := json.NewDecoder(resp.Body).Decode(&root); err != nil {
return nil, err
}
out := make([]jenkinsJob, 0)
if len(root.Jobs) == 0 && isBuildableJenkinsJob(root.Name, root.URL, root.Color, root.Class, root.LastBuild != nil) {
return []jenkinsJob{{
Name: root.Name,
URL: root.URL,
Color: root.Color,
Class: root.Class,
Description: root.Description,
LastBuild: root.LastBuild,
}}, nil
}
merged := map[string]jenkinsJob{}
for _, job := range root.Jobs {
children, err := c.collectJenkinsJob(ctx, job, depth, visited)
if err != nil {
return nil, err
}
mergeJenkinsJobs(merged, children...)
}
for _, view := range root.Views {
for _, job := range view.Jobs {
viewVisited := map[string]bool{}
children, err := c.collectJenkinsJob(ctx, job, depth, viewVisited)
if err != nil {
return nil, err
}
for i := range children {
children[i].ViewName = view.Name
children[i].ViewURL = view.URL
children[i].Views = appendJenkinsJobView(children[i].Views, jenkinsJobView{Name: view.Name, URL: view.URL})
}
mergeJenkinsJobs(merged, children...)
}
}
keys := make([]string, 0, len(merged))
for key := range merged {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
job := merged[key]
out = append(out, job)
}
return out, nil
}
func (c *jenkinsClient) collectJenkinsJob(ctx context.Context, job jenkinsJob, depth int, visited map[string]bool) ([]jenkinsJob, error) {
key := strings.TrimRight(job.URL, "/")
if key == "" || visited[key] {
return nil, nil
}
visited[key] = true
if len(job.Jobs) > 0 || isContainerJenkinsJob(job.Class) {
children, err := c.listJobsAt(ctx, job.URL, depth+1, visited)
if err != nil {
return nil, err
}
if len(children) > 0 {
return children, nil
}
if !isBuildableJenkinsJob(job.Name, job.URL, job.Color, job.Class, job.LastBuild != nil) {
return nil, nil
}
}
if !isBuildableJenkinsJob(job.Name, job.URL, job.Color, job.Class, job.LastBuild != nil) {
return nil, nil
}
return []jenkinsJob{job}, nil
}
func (c *jenkinsClient) jobMetadata(ctx context.Context, jobURL string) jenkinsJobMetadata {
var meta jenkinsJobMetadata
xmlText := c.configXML(ctx, jobURL)
if xmlText == "" {
return meta
}
meta.Source = firstXMLValue(xmlText, `(?s)<hudson\.plugins\.git\.UserRemoteConfig>.*?<url>(.*?)</url>`)
if meta.Source == "" {
meta.Source = firstXMLValue(xmlText, `(?s)<scm[^>]*>.*?<url>(.*?)</url>`)
}
meta.DefaultBranch = normalizeJenkinsBranch(firstXMLValue(xmlText, `(?s)<branches>.*?<name>(.*?)</name>`))
meta.ShellSteps = allXMLValues(xmlText, `(?s)<hudson\.tasks\.Shell>.*?<command>(.*?)</command>`)
meta.Parameters = parseJenkinsConfigParameters(xmlText)
if meta.Source == "" {
meta.Source = sourceFromJenkinsParameters(meta.Parameters)
}
return meta
}
func (c *jenkinsClient) configXML(ctx context.Context, jobURL string) string {
jobURL = strings.TrimRight(jobURL, "/")
if jobURL == "" {
return ""
}
resp, err := c.request(ctx, http.MethodGet, jobURL+"/config.xml", nil)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ""
}
b, _ := io.ReadAll(resp.Body)
return string(b)
}
func firstXMLValue(raw, pattern string) string {
values := allXMLValues(raw, pattern)
if len(values) == 0 {
return ""
}
return values[0]
}
func allXMLValues(raw, pattern string) []string {
re := regexp.MustCompile(pattern)
matches := re.FindAllStringSubmatch(raw, -1)
out := make([]string, 0, len(matches))
for _, match := range matches {
if len(match) < 2 {
continue
}
value := strings.TrimSpace(html.UnescapeString(match[1]))
if value != "" {
out = append(out, value)
}
}
return out
}
func normalizeJenkinsBranch(branch string) string {
branch = strings.TrimSpace(branch)
branch = strings.TrimPrefix(branch, "*/")
branch = strings.TrimPrefix(branch, "origin/")
if branch == "" || strings.ContainsAny(branch, "*$") {
return ""
}
return branch
}
func parseJenkinsConfigParameters(raw string) []jenkinsParameterDefinition {
re := regexp.MustCompile(`(?s)<([^/][^>\s]*ParameterDefinition[^>]*)>(.*?)</[^>]*ParameterDefinition>`)
matches := re.FindAllStringSubmatch(raw, -1)
out := make([]jenkinsParameterDefinition, 0, len(matches))
for _, match := range matches {
if len(match) < 3 {
continue
}
className := strings.TrimSpace(match[1])
body := match[2]
name := firstXMLValue(body, `(?s)<name>(.*?)</name>`)
if name == "" {
continue
}
def := jenkinsParameterDefinition{
Class: className,
Name: name,
Type: className,
Description: firstXMLValue(body, `(?s)<description>(.*?)</description>`),
Choices: parseJenkinsConfigChoices(body),
}
if v := firstXMLValue(body, `(?s)<defaultValue>(.*?)</defaultValue>`); v != "" {
def.Default = v
} else if v := firstXMLValue(body, `(?s)<defaultParameterValue>.*?<value>(.*?)</value>`); v != "" {
def.Default = v
}
out = append(out, def)
}
return out
}
func parseJenkinsConfigChoices(body string) []string {
choicesBlock := firstXMLValue(body, `(?s)<choices[^>]*>(.*?)</choices>`)
if choicesBlock != "" {
choices := allXMLValues(choicesBlock, `(?s)<string>(.*?)</string>`)
if len(choices) > 0 {
return uniqueSortedStrings(choices)
}
}
value := firstXMLValue(body, `(?s)<value>(.*?)</value>`)
if value == "" {
value = firstXMLValue(body, `(?s)<defaultValue>(.*?)</defaultValue>`)
}
if strings.Contains(value, ",") || strings.Contains(value, "\n") {
parts := regexp.MustCompile(`[,\n\r]+`).Split(value, -1)
return uniqueSortedStrings(parts)
}
return nil
}
func mergeJenkinsParameters(primary, fallback []jenkinsParameterDefinition) []jenkinsParameterDefinition {
merged := map[string]jenkinsParameterDefinition{}
order := []string{}
add := func(def jenkinsParameterDefinition) {
name := strings.TrimSpace(def.Name)
if name == "" {
return
}
existing, ok := merged[name]
if !ok {
order = append(order, name)
merged[name] = def
return
}
if existing.Type == "" {
existing.Type = def.Type
}
if existing.Class == "" {
existing.Class = def.Class
}
if existing.Description == "" {
existing.Description = def.Description
}
if len(existing.Choices) == 0 {
existing.Choices = def.Choices
}
if existing.Default == nil {
existing.Default = def.Default
}
merged[name] = existing
}
for _, def := range primary {
add(def)
}
for _, def := range fallback {
add(def)
}
out := make([]jenkinsParameterDefinition, 0, len(order))
for _, name := range order {
out = append(out, merged[name])
}
return out
}
func sourceFromJenkinsParameters(params []jenkinsParameterDefinition) string {
for _, param := range params {
name := strings.ToLower(strings.TrimSpace(param.Name))
if name != "git_url" && name != "giturl" && name != "repo_url" && name != "repository" {
continue
}
if param.Default != nil {
if v := strings.TrimSpace(fmt.Sprint(param.Default)); strings.Contains(v, ".git") || strings.Contains(v, "://") || strings.Contains(v, "@") {
return v
}
}
}
return ""
}
func mergeJenkinsJobs(merged map[string]jenkinsJob, jobs ...jenkinsJob) {
for _, job := range jobs {
key := strings.TrimRight(job.URL, "/")
if key == "" {
key = job.Name
}
existing, ok := merged[key]
if !ok {
job.Views = normalizeJenkinsJobViews(job.Views)
job.ViewName, job.ViewURL = preferredJenkinsJobView(job.Views, job.ViewName, job.ViewURL)
merged[key] = job
continue
}
existing.Views = normalizeJenkinsJobViews(append(existing.Views, job.Views...))
existing.ViewName, existing.ViewURL = preferredJenkinsJobView(existing.Views, existing.ViewName, existing.ViewURL)
if existing.Color == "" {
existing.Color = job.Color
}
if existing.Description == "" {
existing.Description = job.Description
}
if existing.LastBuild == nil {
existing.LastBuild = job.LastBuild
}
merged[key] = existing
}
}
func appendJenkinsJobView(views []jenkinsJobView, view jenkinsJobView) []jenkinsJobView {
if strings.TrimSpace(view.Name) == "" {
return views
}
for _, item := range views {
if item.Name == view.Name && item.URL == view.URL {
return views
}
}
return append(views, view)
}
func normalizeJenkinsJobViews(views []jenkinsJobView) []jenkinsJobView {
out := make([]jenkinsJobView, 0, len(views))
for _, view := range views {
out = appendJenkinsJobView(out, view)
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out
}
func preferredJenkinsJobView(views []jenkinsJobView, fallbackName, fallbackURL string) (string, string) {
for _, view := range views {
if !strings.EqualFold(view.Name, "all") {
return view.Name, view.URL
}
}
for _, view := range views {
if strings.TrimSpace(view.Name) != "" {
return view.Name, view.URL
}
}
return fallbackName, fallbackURL
}
func encodeJenkinsJobViews(views []jenkinsJobView) string {
views = normalizeJenkinsJobViews(views)
if len(views) == 0 {
return ""
}
buf, err := json.Marshal(views)
if err != nil {
return ""
}
return string(buf)
}
func countJenkinsViews(jobs []jenkinsJob) int {
seen := map[string]bool{}
for _, job := range jobs {
for _, view := range job.Views {
if strings.TrimSpace(view.Name) != "" && !strings.EqualFold(view.Name, "all") {
seen[view.Name] = true
}
}
}
return len(seen)
}
func (job jenkinsJob) Parameters() []jenkinsParameterDefinition {
out := []jenkinsParameterDefinition{}
for _, prop := range job.Properties {
for _, def := range prop.ParameterDefinitions {
if strings.TrimSpace(def.Name) == "" {
continue
}
if def.Default == nil && def.DefaultParameterValue != nil {
def.Default = def.DefaultParameterValue.Value
}
out = append(out, def)
}
}
return out
}
func encodeJenkinsParameters(params []jenkinsParameterDefinition) string {
if len(params) == 0 {
return ""
}
buf, err := json.Marshal(params)
if err != nil {
return ""
}
return string(buf)
}
func encodeJenkinsSteps(steps []string) string {
if len(steps) == 0 {
return ""
}
items := make([]gin.H, 0, len(steps))
for i, step := range steps {
items = append(items, gin.H{"type": "shell", "name": fmt.Sprintf("Shell #%d", i+1), "command": step})
}
buf, err := json.Marshal(items)
if err != nil {
return ""
}
return string(buf)
}
func preferredJenkinsRefParam(pipeline model.Pipeline, fallback string) string {
params := decodeJenkinsParameters(pipeline.JenkinsParams)
for _, param := range params {
if strings.EqualFold(param.Name, fallback) {
return param.Name
}
}
for _, param := range params {
if strings.Contains(param.Type, "GitParameterDefinition") || strings.HasPrefix(param.Type, "PT_") {
return param.Name
}
}
return normalizeJenkinsRefParam(fallback)
}
func decodeJenkinsParameters(raw string) []jenkinsParameterDefinition {
var params []jenkinsParameterDefinition
if strings.TrimSpace(raw) == "" {
return params
}
_ = json.Unmarshal([]byte(raw), &params)
return params
}
func jenkinsStaticRefChoices(pipeline model.Pipeline, refType string) []string {
out := []string{}
for _, param := range decodeJenkinsParameters(pipeline.JenkinsParams) {
if len(param.Choices) > 0 {
for _, choice := range param.Choices {
if strings.TrimSpace(choice) != "" {
out = append(out, choice)
}
}
}
if param.Default != nil {
if v := strings.TrimSpace(fmt.Sprint(param.Default)); v != "" {
out = append(out, v)
}
}
}
if refType == "branch" && strings.TrimSpace(pipeline.Branch) != "" {
out = append(out, pipeline.Branch)
}
return uniqueSortedStrings(out)
}
func uniqueSortedStrings(items []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" || seen[item] {
continue
}
seen[item] = true
out = append(out, item)
}
sort.Strings(out)
return out
}
func listGitRefs(ctx context.Context, repoURL, refType string) ([]string, error) {
args := []string{"ls-remote", "--refs"}
if refType == "tag" {
args = append(args, "--tags")
} else {
args = append(args, "--heads")
}
args = append(args, repoURL)
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "git", args...)
out, err := cmd.Output()
if err != nil {
return nil, err
}
prefix := "refs/heads/"
if refType == "tag" {
prefix = "refs/tags/"
}
refs := []string{}
for _, line := range strings.Split(string(out), "\n") {
parts := strings.Fields(line)
if len(parts) < 2 || !strings.HasPrefix(parts[1], prefix) {
continue
}
ref := strings.TrimPrefix(parts[1], prefix)
ref = strings.TrimSuffix(ref, "^{}")
if ref != "" {
refs = append(refs, ref)
}
}
return uniqueSortedStrings(refs), nil
}
func jenkinsLastBuildTime(job jenkinsJob) *time.Time {
if job.LastBuild == nil || job.LastBuild.Timestamp <= 0 {
return nil
}
t := time.UnixMilli(job.LastBuild.Timestamp)
return &t
}
func isContainerJenkinsJob(className string) bool {
className = strings.ToLower(className)
return strings.Contains(className, "folder") ||
strings.Contains(className, "multibranch") ||
strings.Contains(className, "organizationfolder")
}
func isBuildableJenkinsJob(name, jobURL, color, className string, hasLastBuild bool) bool {
if strings.TrimSpace(name) == "" || strings.TrimSpace(jobURL) == "" {
return false
}
if isContainerJenkinsJob(className) {
return false
}
className = strings.ToLower(className)
return color != "" ||
hasLastBuild ||
strings.Contains(className, "workflowjob") ||
strings.Contains(className, "freestyleproject") ||
strings.Contains(className, "mavenmoduleset") ||
strings.Contains(className, "matrixproject") ||
strings.Contains(className, "job")
}
func (c *jenkinsClient) trigger(ctx context.Context, pipeline model.Pipeline, params map[string]string) (string, error) {
if err := c.ensureCrumb(ctx); err != nil {
return "", err
}
jobURL := strings.TrimRight(pipeline.JenkinsJobURL, "/")
if jobURL == "" {
jobURL = c.baseURL + "/job/" + url.PathEscape(pipeline.JenkinsJobName)
}
form := url.Values{}
for k, v := range params {
if strings.TrimSpace(v) != "" {
form.Set(k, v)
}
}
endpoint := jobURL + "/build"
if len(form) > 0 {
endpoint = jobURL + "/buildWithParameters"
}
resp, err := c.request(ctx, http.MethodPost, endpoint, form)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusFound && resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("trigger status %d", resp.StatusCode)
}
queueURL := resp.Header.Get("Location")
if queueURL == "" {
queueURL = jobURL + "/lastBuild"
}
return queueURL, nil
}
func (c *jenkinsClient) waitQueue(ctx context.Context, queueURL string, logFn func(string, string, string)) (jenkinsBuildInfo, error) {
if strings.Contains(queueURL, "/lastBuild") {
return c.getBuild(ctx, queueURL)
}
apiURL := strings.TrimRight(queueURL, "/") + "/api/json"
for {
select {
case <-ctx.Done():
return jenkinsBuildInfo{}, ctx.Err()
case <-time.After(jenkinsPollInterval):
}
resp, err := c.request(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return jenkinsBuildInfo{}, err
}
var item jenkinsQueueItem
err = json.NewDecoder(resp.Body).Decode(&item)
resp.Body.Close()
if err != nil {
return jenkinsBuildInfo{}, err
}
if item.Cancelled {
return jenkinsBuildInfo{}, fmt.Errorf("queue cancelled: %s", item.Why)
}
if item.Executable != nil {
return jenkinsBuildInfo{Number: item.Executable.Number, URL: item.Executable.URL, Building: true}, nil
}
if item.Why != "" {
logFn("info", "Jenkins", ">>> 等待队列: "+item.Why)
}
}
}
func (c *jenkinsClient) waitBuild(ctx context.Context, buildURL string, logFn func(string, string, string)) (jenkinsBuildInfo, error) {
var consoleOffset int64
for {
build, err := c.getBuild(ctx, buildURL)
if err != nil {
return build, err
}
console, nextOffset, _ := c.progressiveConsoleText(ctx, buildURL, consoleOffset)
if console != "" {
logJenkinsConsoleChunk(logFn, console)
consoleOffset = nextOffset
}
if !build.Building && build.Result != "" {
console, _, _ := c.progressiveConsoleText(ctx, buildURL, consoleOffset)
if console != "" {
logJenkinsConsoleChunk(logFn, console)
}
return build, nil
}
logFn("info", "Jenkins", fmt.Sprintf(">>> Build #%d 仍在执行...", build.Number))
select {
case <-ctx.Done():
return build, ctx.Err()
case <-time.After(jenkinsPollInterval):
}
}
}
func (c *jenkinsClient) getBuild(ctx context.Context, buildURL string) (jenkinsBuildInfo, error) {
resp, err := c.request(ctx, http.MethodGet, strings.TrimRight(buildURL, "/")+"/api/json", nil)
if err != nil {
return jenkinsBuildInfo{}, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return jenkinsBuildInfo{}, fmt.Errorf("build api status %d", resp.StatusCode)
}
var info jenkinsBuildInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return info, err
}
if info.URL == "" {
info.URL = strings.TrimRight(buildURL, "/")
}
return info, nil
}
func (c *jenkinsClient) consoleText(ctx context.Context, buildURL string) string {
resp, err := c.request(ctx, http.MethodGet, strings.TrimRight(buildURL, "/")+"/consoleText", nil)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return ""
}
b, _ := io.ReadAll(resp.Body)
return string(b)
}
func (c *jenkinsClient) progressiveConsoleText(ctx context.Context, buildURL string, start int64) (string, int64, bool) {
endpoint := fmt.Sprintf("%s/logText/progressiveText?start=%d", strings.TrimRight(buildURL, "/"), start)
resp, err := c.request(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return "", start, false
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", start, false
}
b, _ := io.ReadAll(resp.Body)
next := start
if h := resp.Header.Get("X-Text-Size"); h != "" {
if n, err := strconv.ParseInt(h, 10, 64); err == nil {
next = n
}
} else {
next = start + int64(len(b))
}
return string(b), next, strings.EqualFold(resp.Header.Get("X-More-Data"), "true")
}
func logJenkinsConsoleChunk(logFn func(string, string, string), chunk string) {
normalized := strings.ReplaceAll(chunk, "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\r", "\n")
for _, line := range strings.Split(normalized, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
logFn("info", "Console", line)
}
}
func pipelineCodeFromJenkinsJob(prefix, name string) string {
parts := []string{}
if strings.TrimSpace(prefix) != "" {
parts = append(parts, strings.TrimSpace(prefix))
}
parts = append(parts, strings.TrimSpace(name))
raw := strings.ToLower(strings.Join(parts, "-"))
re := regexp.MustCompile(`[^a-z0-9]+`)
code := strings.Trim(re.ReplaceAllString(raw, "-"), "-")
if code == "" {
code = "jenkins-job"
}
if len(code) > 54 {
sum := sha1.Sum([]byte(raw))
code = strings.Trim(code[:54], "-") + "-" + hex.EncodeToString(sum[:])[:8]
}
return code
}
func (h *PipelineHandler) uniquePipelineCode(base string) string {
code := base
for i := 0; i < 100; i++ {
var count int64
h.db.Model(&model.Pipeline{}).Where("code = ? OR job_name = ?", code, code).Count(&count)
if count == 0 {
return code
}
code = fmt.Sprintf("%s-%d", strings.TrimRight(base, "-"), i+1)
if len(code) > 64 {
code = code[:64]
}
}
return base + "-jenkins"
}
func jenkinsColorToStatus(color string) string {
color = strings.ToLower(color)
switch {
case strings.Contains(color, "blue"):
return "success"
case strings.Contains(color, "red"):
return "failed"
case strings.Contains(color, "anime"):
return "running"
default:
return ""
}
}