1363 lines
41 KiB
Go
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), ¶ms)
|
|
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 ""
|
|
}
|
|
}
|