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

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

314 lines
8.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
}
}