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 数据文件
This commit is contained in:
1 parent
04d1342b27
commit
26341c3e8a
37 files changed
+5702
-4683
No files matched your search
@@ -0,0 +1,313 @@
|
||||
// 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"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user