后端(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 数据文件
314 lines
8.4 KiB
Go
314 lines
8.4 KiB
Go
// Package jenkins Jenkins REST 客户端(移植自 opsx-jenkins 生产实现):
|
||
// crumb → BasicAuth → buildWithParameters → Location 队列 URL → 队列/构建轮询 → progressiveText 增量控制台。
|
||
package jenkins
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type Client struct {
|
||
BaseURL string
|
||
Username string
|
||
Token string
|
||
HTTP *http.Client
|
||
}
|
||
|
||
func NewClient(baseURL, username, token string) *Client {
|
||
return &Client{
|
||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||
Username: username,
|
||
Token: token,
|
||
HTTP: &http.Client{Timeout: 30 * time.Second},
|
||
}
|
||
}
|
||
|
||
// JobInfo Job 概要(folder 已压平为 path/name 形式)
|
||
type JobInfo struct {
|
||
Name string `json:"name"` // 压平路径 folder/job
|
||
URL string `json:"url"`
|
||
Type string `json:"type"` // folder | freestyle | pipeline | multibranch
|
||
Status string `json:"status"` // success | failed | running | unknown
|
||
}
|
||
|
||
func (c *Client) do(method, path string, form url.Values) (*http.Response, error) {
|
||
var body io.Reader
|
||
if form != nil {
|
||
body = strings.NewReader(form.Encode())
|
||
}
|
||
req, err := http.NewRequest(method, c.BaseURL+path, body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.SetBasicAuth(c.Username, c.Token)
|
||
if form != nil {
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
}
|
||
// CSRF crumb(未启用时 /crumbIssuer 返回 404,跳过)
|
||
if crumb, err := c.crumb(); err == nil {
|
||
req.Header.Set(crumb[0], crumb[1])
|
||
}
|
||
return c.HTTP.Do(req)
|
||
}
|
||
|
||
func (c *Client) crumb() ([2]string, error) {
|
||
req, _ := http.NewRequest("GET", c.BaseURL+"/crumbIssuer/api/json", nil)
|
||
req.SetBasicAuth(c.Username, c.Token)
|
||
resp, err := c.HTTP.Do(req)
|
||
if err != nil {
|
||
return [2]string{}, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode == http.StatusNotFound {
|
||
return [2]string{}, errors.New("crumb not enabled")
|
||
}
|
||
var out struct {
|
||
CrumbRequestField string `json:"crumbRequestField"`
|
||
Crumb string `json:"crumb"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Crumb == "" {
|
||
return [2]string{}, errors.New("no crumb")
|
||
}
|
||
return [2]string{out.CrumbRequestField, out.Crumb}, nil
|
||
}
|
||
|
||
// Test 连通测试:访问 /api/json
|
||
func (c *Client) Test() error {
|
||
resp, err := c.do("GET", "/api/json", nil)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode == http.StatusUnauthorized {
|
||
return errors.New("认证失败:用户名或 Token 错误")
|
||
}
|
||
if resp.StatusCode != http.StatusOK {
|
||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type rawJob struct {
|
||
Name string `json:"name"`
|
||
URL string `json:"url"`
|
||
Color string `json:"color"`
|
||
Jobs []rawJob `json:"jobs"`
|
||
}
|
||
|
||
// colorToStatus Jenkins ball color → 状态
|
||
func colorToStatus(color string) string {
|
||
switch {
|
||
case color == "":
|
||
return "unknown"
|
||
case strings.HasSuffix(color, "anime"):
|
||
return "running"
|
||
case strings.HasPrefix(color, "blue"):
|
||
return "success"
|
||
case strings.HasPrefix(color, "red"):
|
||
return "failed"
|
||
case strings.HasPrefix(color, "notbuilt"), strings.HasPrefix(color, "disabled"), strings.HasPrefix(color, "aborted"):
|
||
return "unknown"
|
||
default:
|
||
return "unknown"
|
||
}
|
||
}
|
||
|
||
// ListJobs 递归 3 层拉取 Job 树并压平 folder
|
||
func (c *Client) ListJobs() ([]JobInfo, error) {
|
||
resp, err := c.do("GET", "/api/json?tree=jobs[name,url,color,jobs[name,url,color,jobs[name,url,color,jobs[name,url,color]]]]", nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode != http.StatusOK {
|
||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||
}
|
||
var root struct {
|
||
Jobs []rawJob `json:"jobs"`
|
||
}
|
||
if err := json.NewDecoder(resp.Body).Decode(&root); err != nil {
|
||
return nil, err
|
||
}
|
||
var flat []JobInfo
|
||
var walk func(jobs []rawJob, prefix string)
|
||
walk = func(jobs []rawJob, prefix string) {
|
||
for _, j := range jobs {
|
||
full := j.Name
|
||
if prefix != "" {
|
||
full = prefix + "/" + j.Name
|
||
}
|
||
if len(j.Jobs) > 0 {
|
||
flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "folder", Status: "unknown"})
|
||
walk(j.Jobs, full)
|
||
} else {
|
||
flat = append(flat, JobInfo{Name: full, URL: j.URL, Type: "pipeline", Status: colorToStatus(j.Color)})
|
||
}
|
||
}
|
||
}
|
||
walk(root.Jobs, "")
|
||
return flat, nil
|
||
}
|
||
|
||
// jobPathToURL folder/job → /job/folder/job/job/name
|
||
func jobPathToURL(jobPath string) string {
|
||
segs := strings.Split(jobPath, "/")
|
||
var b strings.Builder
|
||
for _, s := range segs {
|
||
b.WriteString("/job/")
|
||
b.WriteString(url.PathEscape(s))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// Trigger 触发构建,返回队列 URL(Location 头;拒绝回退 lastBuild 防串号)
|
||
func (c *Client) Trigger(jobPath string, params map[string]string) (string, error) {
|
||
path := jobPathToURL(jobPath)
|
||
var form url.Values
|
||
if len(params) > 0 {
|
||
form = url.Values{}
|
||
for k, v := range params {
|
||
form.Set(k, v)
|
||
}
|
||
path += "/buildWithParameters"
|
||
} else {
|
||
path += "/build"
|
||
}
|
||
resp, err := c.do("POST", path, form)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 {
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||
return "", fmt.Errorf("触发失败 HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||
}
|
||
queueURL := resp.Header.Get("Location")
|
||
if queueURL == "" {
|
||
return "", errors.New("未返回队列地址(Location),请检查 Jenkins 版本与权限")
|
||
}
|
||
return queueURL, nil
|
||
}
|
||
|
||
// WaitQueue 等待队列分配为具体构建,返回构建号与构建 URL
|
||
func (c *Client) WaitQueue(queueURL string, timeout time.Duration, emit func(string)) (int, string, error) {
|
||
deadline := time.Now().Add(timeout)
|
||
pollClient := &http.Client{Timeout: 15 * time.Second}
|
||
for time.Now().Before(deadline) {
|
||
req, _ := http.NewRequest("GET", queueURL+"api/json", nil)
|
||
req.SetBasicAuth(c.Username, c.Token)
|
||
resp, err := pollClient.Do(req)
|
||
if err == nil {
|
||
var q struct {
|
||
Cancelled bool `json:"cancelled"`
|
||
Executable *struct {
|
||
Number int `json:"number"`
|
||
URL string `json:"url"`
|
||
} `json:"executable"`
|
||
Why string `json:"why"`
|
||
}
|
||
if json.NewDecoder(resp.Body).Decode(&q) == nil {
|
||
resp.Body.Close()
|
||
if q.Cancelled {
|
||
return 0, "", errors.New("构建已被 Jenkins 取消(排队被拒绝)")
|
||
}
|
||
if q.Executable != nil {
|
||
return q.Executable.Number, strings.TrimRight(q.Executable.URL, "/"), nil
|
||
}
|
||
if q.Why != "" {
|
||
emit("排队中: " + q.Why)
|
||
}
|
||
} else {
|
||
resp.Body.Close()
|
||
}
|
||
}
|
||
time.Sleep(2 * time.Second)
|
||
}
|
||
return 0, "", errors.New("等待队列超时")
|
||
}
|
||
|
||
// StreamConsole 增量拉取控制台日志(X-Text-Size/X-More-Data 推进),直到构建结束
|
||
func (c *Client) StreamConsole(buildURL string, emit func(string)) {
|
||
offset := 0
|
||
pollClient := &http.Client{Timeout: 15 * time.Second}
|
||
for {
|
||
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/logText/progressiveText?start=%d", buildURL, offset), nil)
|
||
req.SetBasicAuth(c.Username, c.Token)
|
||
resp, err := pollClient.Do(req)
|
||
if err != nil {
|
||
emit("日志拉取失败: " + err.Error())
|
||
return
|
||
}
|
||
body, _ := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
if len(body) > 0 {
|
||
for _, line := range strings.Split(strings.TrimRight(string(body), "\n"), "\n") {
|
||
if line != "" {
|
||
emit(line)
|
||
}
|
||
}
|
||
}
|
||
if newSize := parseInt(resp.Header.Get("X-Text-Size")); newSize > offset {
|
||
offset = newSize
|
||
}
|
||
if resp.Header.Get("X-More-Data") != "true" {
|
||
return
|
||
}
|
||
time.Sleep(2 * time.Second)
|
||
}
|
||
}
|
||
|
||
// WaitResult 轮询构建结果:SUCCESS/FAILURE/ABORTED/null(进行中)
|
||
func (c *Client) WaitResult(buildURL string, timeout time.Duration) (string, error) {
|
||
deadline := time.Now().Add(timeout)
|
||
for time.Now().Before(deadline) {
|
||
req, _ := http.NewRequest("GET", buildURL+"/api/json", nil)
|
||
req.SetBasicAuth(c.Username, c.Token)
|
||
resp, err := c.HTTP.Do(req)
|
||
if err == nil {
|
||
var b struct {
|
||
Building bool `json:"building"`
|
||
Result *string `json:"result"`
|
||
}
|
||
if json.NewDecoder(resp.Body).Decode(&b) == nil {
|
||
resp.Body.Close()
|
||
if !b.Building && b.Result != nil {
|
||
return *b.Result, nil
|
||
}
|
||
} else {
|
||
resp.Body.Close()
|
||
}
|
||
}
|
||
time.Sleep(3 * time.Second)
|
||
}
|
||
return "", errors.New("等待构建结果超时")
|
||
}
|
||
|
||
func parseInt(s string) int {
|
||
n := 0
|
||
for _, ch := range s {
|
||
if ch < '0' || ch > '9' {
|
||
return n
|
||
}
|
||
n = n*10 + int(ch-'0')
|
||
}
|
||
return n
|
||
}
|
||
|
||
// MapResult Jenkins 结果 → FlowStack 运行状态
|
||
func MapResult(r string) string {
|
||
switch r {
|
||
case "SUCCESS":
|
||
return "success"
|
||
case "ABORTED":
|
||
return "cancelled"
|
||
default:
|
||
return "failed"
|
||
}
|
||
}
|