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
@@ -1,15 +1,132 @@
|
||||
// Package crypto 提供口令哈希原语(argon2id,与 opsx-loki 同参数)。
|
||||
// Package crypto 提供口令哈希(argon2id)与字段加密(AES-256-GCM,enc:v1: 前缀)原语。
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const encPrefix = "enc:v1:"
|
||||
|
||||
var (
|
||||
encKey []byte
|
||||
encKeyOnce sync.Once
|
||||
encKeyErr error
|
||||
)
|
||||
|
||||
// InitEncryptionKey 初始化字段加密密钥:
|
||||
// 优先环境变量 FLOWSTACK_ENCRYPTION_KEY,否则首次启动生成 32 字节随机密钥
|
||||
// 写入 dataDir/encryption.key(0600),重启复用。
|
||||
func InitEncryptionKey(dataDir string) error {
|
||||
encKeyOnce.Do(func() {
|
||||
if env := os.Getenv("FLOWSTACK_ENCRYPTION_KEY"); env != "" {
|
||||
encKey = []byte(env)
|
||||
return
|
||||
}
|
||||
keyFile := filepath.Join(dataDir, "encryption.key")
|
||||
if data, err := os.ReadFile(keyFile); err == nil && len(data) == 32 {
|
||||
encKey = data
|
||||
return
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
encKeyErr = err
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(keyFile, key, 0o600); err != nil {
|
||||
encKeyErr = err
|
||||
return
|
||||
}
|
||||
encKey = key
|
||||
})
|
||||
return encKeyErr
|
||||
}
|
||||
|
||||
func gcm() (cipher.AEAD, error) {
|
||||
if encKey == nil {
|
||||
return nil, errors.New("加密密钥未初始化")
|
||||
}
|
||||
block, err := aes.NewCipher(encKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// EncryptField AES-256-GCM 加密,返回 enc:v1:base64;空串原样返回
|
||||
func EncryptField(plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
aead, err := gcm()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, aead.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := aead.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return encPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// DecryptField 解密 enc:v1: 前缀密文;非该格式原样返回(向后兼容)
|
||||
func DecryptField(encrypted string) (string, error) {
|
||||
if !strings.HasPrefix(encrypted, encPrefix) {
|
||||
return encrypted, nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(encrypted, encPrefix))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aead, err := gcm()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(data) < aead.NonceSize() {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := data[:aead.NonceSize()], data[aead.NonceSize():]
|
||||
plain, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
|
||||
// IsEncrypted 判断字段是否为加密格式
|
||||
func IsEncrypted(v string) bool {
|
||||
return strings.HasPrefix(v, encPrefix)
|
||||
}
|
||||
|
||||
// Mask 敏感值脱敏展示(保留前后各 1/4,中间星号,最长 24 位)
|
||||
func Mask(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
r := []rune(s)
|
||||
if len(r) <= 4 {
|
||||
return strings.Repeat("*", len(r))
|
||||
}
|
||||
keep := len(r) / 4
|
||||
if keep > 4 {
|
||||
keep = 4
|
||||
}
|
||||
return string(r[:keep]) + strings.Repeat("*", 6) + string(r[len(r)-keep:])
|
||||
}
|
||||
|
||||
// HashPassword 生成 argon2id 口令哈希(salt+hash base64 编码)
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -23,6 +24,9 @@ func Init() error {
|
||||
if err := os.MkdirAll(DataDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := crypto.InitEncryptionKey(DataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(DataDir, "flowstack.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
@@ -35,12 +39,16 @@ func Init() error {
|
||||
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
|
||||
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
|
||||
&model.Application{}, &model.Plugin{},
|
||||
&model.Host{}, &model.K8sCluster{}, &model.Credential{}, &model.BuildNode{},
|
||||
&model.DeployTool{}, &model.WebhookEndpoint{}, &model.JenkinsServer{}, &model.SystemSetting{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seedAdmin()
|
||||
seedDemoData()
|
||||
seedResources()
|
||||
seedTools()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -153,9 +161,10 @@ func seedDemoData() {
|
||||
})
|
||||
|
||||
// ---- GitOps 应用 ----
|
||||
now := time.Now()
|
||||
DB.Create([]model.Application{
|
||||
{Name: "aiops-backend", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-backend", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "automated", AutoPrune: true, SelfHeal: true},
|
||||
{Name: "aiops-web", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-web", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "manual"},
|
||||
{Name: "aiops-backend", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-backend", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "automated", AutoPrune: true, SelfHeal: true, SyncStatus: "Synced", HealthStatus: "Healthy", LastSyncAt: &now},
|
||||
{Name: "aiops-web", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-web", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "manual", SyncStatus: "OutOfSync", HealthStatus: "Progressing"},
|
||||
})
|
||||
|
||||
// ---- 已安装插件 ----
|
||||
@@ -165,3 +174,69 @@ func seedDemoData() {
|
||||
{PluginID: "k8s-rollout", Name: "K8s 滚动发布", Description: "Deployment 滚动更新与回滚", Version: "2.3.1", Author: "CloudNative", Category: "构建", Enabled: false},
|
||||
})
|
||||
}
|
||||
|
||||
// seedResources 资源示例数据(主机/集群/凭证/构建节点/Webhook,各表独立判空)
|
||||
func seedResources() {
|
||||
var n int64
|
||||
DB.Model(&model.Host{}).Count(&n)
|
||||
if n == 0 {
|
||||
DB.Create([]model.Host{
|
||||
{Name: "build-host-01", IP: "10.0.8.11", SSHPort: 22, Username: "root", Tags: `["build","docker"]`, Status: "online", OS: "Ubuntu 22.04", Remark: "通用构建机"},
|
||||
{Name: "prod-web-01", IP: "10.0.1.21", SSHPort: 22, Username: "admin", Tags: `["production","web"]`, Status: "online", OS: "CentOS 7.9", Remark: "Nginx 前端"},
|
||||
})
|
||||
}
|
||||
DB.Model(&model.K8sCluster{}).Count(&n)
|
||||
if n == 0 {
|
||||
DB.Create([]model.K8sCluster{
|
||||
{Name: "ack-production", Context: "ack-production", APIServer: "https://k8s.prod:6443", Version: "v1.30.1", NodeCount: 3, Namespaces: `["aiops","default","kube-system"]`, Status: "disconnected"},
|
||||
})
|
||||
}
|
||||
DB.Model(&model.Credential{}).Count(&n)
|
||||
if n == 0 {
|
||||
enc1, _ := crypto.EncryptField(`{"private_key":"CHANGE_ME","ssh_user":"root","ssh_port":22}`)
|
||||
DB.Create([]model.Credential{
|
||||
{Name: "prod-ssh-key", Type: "ssh", Scope: "production-group", Content: enc1, Summary: "ssh_key root@22"},
|
||||
{Name: "registry-example", Type: "docker", Scope: "全局", Summary: "docker registry.example.com aiops-pull"},
|
||||
})
|
||||
}
|
||||
DB.Model(&model.BuildNode{}).Count(&n)
|
||||
if n == 0 {
|
||||
hb := time.Now().Add(-10 * time.Second)
|
||||
DB.Create([]model.BuildNode{
|
||||
{Name: "build-host-01", IP: "10.0.8.11", Labels: `["docker","go","java"]`, MaxConcurrency: 4, Status: "online", CurrentJobs: 1, LastHeartbeat: &hb},
|
||||
{Name: "build-host-02", IP: "10.0.8.12", Labels: `["docker","nodejs"]`, MaxConcurrency: 2, Status: "busy", CurrentJobs: 2, LastHeartbeat: &hb},
|
||||
{Name: "build-host-03", IP: "10.0.8.13", Labels: `["python"]`, MaxConcurrency: 2, Status: "offline", LastHeartbeat: &hb},
|
||||
})
|
||||
}
|
||||
DB.Model(&model.WebhookEndpoint{}).Count(&n)
|
||||
if n == 0 {
|
||||
DB.Create([]model.WebhookEndpoint{
|
||||
{Name: "发布成功通知", URL: "https://open.feishu.cn/open-apis/bot/v2/hook/CHANGE_ME", Events: `["pipeline.success","pipeline.failed"]`, Active: true, LastStatus: "none"},
|
||||
})
|
||||
}
|
||||
DB.Model(&model.SystemSetting{}).Count(&n)
|
||||
if n == 0 {
|
||||
DB.Create(&model.SystemSetting{
|
||||
ID: 1, ToolMode: "local",
|
||||
Theme: `{"mode":"light","primaryColor":"#0969da","fontSize":"medium","density":"comfortable","sidebarStyle":"full","borderRadius":"soft"}`,
|
||||
Notification: `{"email":false,"browser":true,"pipelineSuccess":true,"pipelineFailed":true,"securityAlert":false}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// seedTools 工具清单(CheckCommand 为真实版本探测命令,安装走保守检测策略)
|
||||
func seedTools() {
|
||||
var n int64
|
||||
DB.Model(&model.DeployTool{}).Count(&n)
|
||||
if n > 0 {
|
||||
return
|
||||
}
|
||||
DB.Create([]model.DeployTool{
|
||||
{ToolKey: "jdk", DisplayName: "JDK", Category: "build", Version: "17", InstallMode: "preinstalled", Path: "/usr/lib/jvm/java-17", CheckCommand: "java -version", SortOrder: 1},
|
||||
{ToolKey: "maven", DisplayName: "Maven", Category: "build", Version: "3.9", InstallMode: "lazy", Path: "/opt/maven", CheckCommand: "mvn -version", SortOrder: 2},
|
||||
{ToolKey: "golang", DisplayName: "Golang", Category: "build", Version: "1.24", InstallMode: "preinstalled", Path: "/usr/local/go", CheckCommand: "go version", SortOrder: 3},
|
||||
{ToolKey: "nodejs", DisplayName: "Node.js", Category: "build", Version: "20", InstallMode: "lazy", Path: "/usr/local/nvm", CheckCommand: "node -v", SortOrder: 4},
|
||||
{ToolKey: "git", DisplayName: "Git", Category: "build", Version: "2.40", InstallMode: "preinstalled", Path: "/usr/bin/git", CheckCommand: "git --version", SortOrder: 5},
|
||||
{ToolKey: "docker-cli", DisplayName: "Docker CLI", Category: "deploy", Version: "26", InstallMode: "lazy", Path: "/usr/bin/docker", CheckCommand: "docker --version", SortOrder: 6},
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -9,16 +10,6 @@ import (
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// argoApps 模拟 Argo CD 应用状态(演示用)
|
||||
var argoApps = []gin.H{
|
||||
{"metadata": gin.H{"name": "aiops-backend"},
|
||||
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-backend", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
|
||||
"status": gin.H{"sync": gin.H{"status": "Synced"}, "health": gin.H{"status": "Healthy"}, "operationState": gin.H{"phase": "Succeeded"}}},
|
||||
{"metadata": gin.H{"name": "aiops-web"},
|
||||
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-web", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
|
||||
"status": gin.H{"sync": gin.H{"status": "OutOfSync"}, "health": gin.H{"status": "Progressing"}, "operationState": gin.H{"phase": "Running"}}},
|
||||
}
|
||||
|
||||
// ListGitOpsApps GET /api/gitops/apps
|
||||
func ListGitOpsApps(c *gin.Context) {
|
||||
var apps []model.Application
|
||||
@@ -26,12 +17,33 @@ func ListGitOpsApps(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apps)
|
||||
}
|
||||
|
||||
// ArgoList GET /api/gitops/argo/apps
|
||||
// ArgoList GET /api/gitops/argo/apps —— 从 Application 表派生 Argo 风格状态
|
||||
func ArgoList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, argoApps)
|
||||
var apps []model.Application
|
||||
database.DB.Order("updated_at DESC").Find(&apps)
|
||||
result := make([]gin.H, 0, len(apps))
|
||||
for _, a := range apps {
|
||||
phase := "Succeeded"
|
||||
if a.SyncStatus == "OutOfSync" {
|
||||
phase = "Running"
|
||||
}
|
||||
result = append(result, gin.H{
|
||||
"metadata": gin.H{"name": a.Name},
|
||||
"spec": gin.H{
|
||||
"source": gin.H{"repoURL": a.Repo, "path": a.Path, "targetRevision": a.Branch},
|
||||
"destination": gin.H{"server": "https://" + a.Cluster, "namespace": a.Namespace},
|
||||
},
|
||||
"status": gin.H{
|
||||
"sync": gin.H{"status": a.SyncStatus},
|
||||
"health": gin.H{"status": a.HealthStatus},
|
||||
"operationState": gin.H{"phase": phase},
|
||||
},
|
||||
})
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// ArgoSync POST /api/gitops/argo/sync {appName}
|
||||
// ArgoSync POST /api/gitops/argo/sync {appName} —— 真实推进 Application 同步状态
|
||||
func ArgoSync(c *gin.Context) {
|
||||
var body struct {
|
||||
AppName string `json:"appName" binding:"required"`
|
||||
@@ -40,6 +52,22 @@ func ArgoSync(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "appName required"})
|
||||
return
|
||||
}
|
||||
var app model.Application
|
||||
if err := database.DB.Where("name = ?", body.AppName).First(&app).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "application not found"})
|
||||
return
|
||||
}
|
||||
database.DB.Model(&app).Updates(map[string]any{
|
||||
"sync_status": "Syncing", "health_status": "Progressing",
|
||||
})
|
||||
// 异步完成同步(3 秒后 Synced/Healthy),模拟 Argo 同步推进
|
||||
go func(id uint, name string) {
|
||||
time.Sleep(3 * time.Second)
|
||||
done := time.Now()
|
||||
database.DB.Model(&model.Application{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"sync_status": "Synced", "health_status": "Healthy", "last_sync_at": &done,
|
||||
})
|
||||
}(app.ID, app.Name)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "appName": body.AppName, "message": "Sync triggered"})
|
||||
}
|
||||
|
||||
@@ -56,12 +84,14 @@ func ArgoResourceTree(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ArgoDiff GET /api/gitops/argo/:appName/diff
|
||||
// ArgoDiff GET /api/gitops/argo/:appName/diff —— 基于 Application 与目标镜像 tag 生成 diff
|
||||
func ArgoDiff(c *gin.Context) {
|
||||
name := c.Param("appName")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"diffs": []gin.H{
|
||||
{"kind": "Deployment", "name": name, "namespace": "aiops", "diff": "- image: registry.example.com/aiops/" + name + ":202610261130\n+ image: registry.example.com/aiops/" + name + ":202610271200", "status": "modified"},
|
||||
{"kind": "Deployment", "name": name, "namespace": "aiops",
|
||||
"diff": "- image: registry.example.com/aiops/" + name + ":" + time.Now().Add(-24*time.Hour).Format("200601021500") + "\n+ image: registry.example.com/aiops/" + name + ":" + time.Now().Format("200601021500"),
|
||||
"status": "modified"},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/crypto"
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/jenkins"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
func clientFromServer(s model.JenkinsServer) *jenkins.Client {
|
||||
token := s.Token
|
||||
if crypto.IsEncrypted(token) {
|
||||
if plain, err := crypto.DecryptField(token); err == nil {
|
||||
token = plain
|
||||
}
|
||||
}
|
||||
return jenkins.NewClient(s.BaseURL, s.Username, token)
|
||||
}
|
||||
|
||||
// ---- Jenkins 服务器 CRUD ----
|
||||
|
||||
func ListJenkinsServers(c *gin.Context) {
|
||||
var list []model.JenkinsServer
|
||||
database.DB.Order("updated_at DESC").Find(&list)
|
||||
for i := range list {
|
||||
list[i].TokenSet = list[i].Token != ""
|
||||
list[i].Token = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func CreateJenkinsServer(c *gin.Context) {
|
||||
var raw map[string]any
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
name, _ := raw["name"].(string)
|
||||
baseURL, _ := raw["baseUrl"].(string)
|
||||
if name == "" || baseURL == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和地址不能为空"})
|
||||
return
|
||||
}
|
||||
s := model.JenkinsServer{Name: name, BaseURL: baseURL, Enabled: true}
|
||||
s.Username, _ = raw["username"].(string)
|
||||
if token, ok := raw["token"].(string); ok && token != "" {
|
||||
enc, err := crypto.EncryptField(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Token 加密失败"})
|
||||
return
|
||||
}
|
||||
s.Token = enc
|
||||
}
|
||||
if err := database.DB.Create(&s).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||
return
|
||||
}
|
||||
s.TokenSet = s.Token != ""
|
||||
s.Token = ""
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
func UpdateJenkinsServer(c *gin.Context) {
|
||||
var s model.JenkinsServer
|
||||
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
|
||||
return
|
||||
}
|
||||
var raw map[string]any
|
||||
_ = c.ShouldBindJSON(&raw)
|
||||
updates := map[string]any{}
|
||||
for _, k := range []string{"name", "baseUrl", "username", "enabled", "remark"} {
|
||||
if v, ok := raw[k]; ok {
|
||||
key := k
|
||||
if k == "baseUrl" {
|
||||
key = "base_url"
|
||||
}
|
||||
updates[key] = v
|
||||
}
|
||||
}
|
||||
if token, ok := raw["token"].(string); ok && token != "" {
|
||||
if enc, err := crypto.EncryptField(token); err == nil {
|
||||
updates["token"] = enc
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
database.DB.Model(&s).Updates(updates)
|
||||
}
|
||||
database.DB.First(&s, s.ID)
|
||||
s.TokenSet = s.Token != ""
|
||||
s.Token = ""
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
func DeleteJenkinsServer(c *gin.Context) {
|
||||
database.DB.Delete(&model.JenkinsServer{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// TestJenkinsServer POST /api/jenkins-servers/:id/test —— 真实连通测试
|
||||
func TestJenkinsServer(c *gin.Context) {
|
||||
var s model.JenkinsServer
|
||||
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
err := clientFromServer(s).Test()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": err == nil, "latencyMs": time.Since(start).Milliseconds(),
|
||||
"message": func() string {
|
||||
if err != nil {
|
||||
return "连接失败: " + err.Error()
|
||||
}
|
||||
return "连接成功,认证通过"
|
||||
}(),
|
||||
})
|
||||
}
|
||||
|
||||
// ListJenkinsJobs GET /api/jenkins-servers/:id/jobs —— 真实拉取 Job 树(folder 已压平)
|
||||
func ListJenkinsJobs(c *gin.Context) {
|
||||
var s model.JenkinsServer
|
||||
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
|
||||
return
|
||||
}
|
||||
jobs, err := clientFromServer(s).ListJobs()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "拉取 Job 失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, jobs)
|
||||
}
|
||||
|
||||
// ImportJenkinsJobs POST /api/jenkins-servers/:id/import —— 导入 Job 为 jenkins 类型流水线
|
||||
func ImportJenkinsJobs(c *gin.Context) {
|
||||
var s model.JenkinsServer
|
||||
if err := database.DB.First(&s, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "jenkins server not found"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Jobs []string `json:"jobs"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || len(body.Jobs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要导入的 Job"})
|
||||
return
|
||||
}
|
||||
|
||||
imported, skipped := 0, 0
|
||||
for _, jobPath := range body.Jobs {
|
||||
var exist model.Pipeline
|
||||
if err := database.DB.Where("jenkins_job_name = ? AND jenkins_server_id = ?", jobPath, s.ID).First(&exist).Error; err == nil {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
name := jobPath
|
||||
if len(name) > 60 {
|
||||
name = name[len(name)-60:]
|
||||
}
|
||||
p := model.Pipeline{
|
||||
Name: name, Description: "从 Jenkins 导入 (" + s.Name + ")",
|
||||
Type: "jenkins", SourceBranch: "main", DeployType: "host", Env: "staging",
|
||||
JenkinsJobName: jobPath, JenkinsServerID: s.ID,
|
||||
}
|
||||
// 名称冲突时追加后缀保证唯一
|
||||
base := name
|
||||
for i := 0; ; i++ {
|
||||
var cnt int64
|
||||
database.DB.Model(&model.Pipeline{}).Where("name = ?", p.Name).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
break
|
||||
}
|
||||
if i >= 5 {
|
||||
p.Name = fmt.Sprintf("%s-%d", base, time.Now().UnixMilli())
|
||||
break
|
||||
}
|
||||
p.Name = fmt.Sprintf("%s-%d", base, i+1)
|
||||
}
|
||||
database.DB.Create(&p)
|
||||
imported++
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "imported": imported, "skipped": skipped})
|
||||
}
|
||||
|
||||
// jenkinsRunEngine jenkins 类型流水线的真实执行引擎:
|
||||
// 触发 → 队列 → 增量控制台 → 结果映射,全程写 LogEntry + SSE 推送
|
||||
func jenkinsRunEngine(hub *Hub, run model.PipelineRun, client *jenkins.Client, jobName string) {
|
||||
start := time.Now()
|
||||
emit := func(msg string) {
|
||||
entry := model.LogEntry{RunID: run.ID, StageID: 0, Level: "info", Message: msg, Source: "jenkins", Timestamp: time.Now()}
|
||||
database.DB.Create(&entry)
|
||||
hub.Publish(run.RunID, "log", entry)
|
||||
}
|
||||
setRun := func(status string) {
|
||||
now := time.Now()
|
||||
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
|
||||
"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": &now,
|
||||
})
|
||||
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
||||
if status == "success" || status == "failed" {
|
||||
NotifyWebhookEvent("pipeline."+status, map[string]any{
|
||||
"runId": run.RunID, "pipeline": run.PipelineName, "durationMs": time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emit("$ jenkins build \"" + jobName + "\"")
|
||||
queueURL, err := client.Trigger(jobName, nil)
|
||||
if err != nil {
|
||||
emit("触发失败: " + err.Error())
|
||||
setRun("failed")
|
||||
return
|
||||
}
|
||||
emit("已进入 Jenkins 构建队列…")
|
||||
|
||||
buildNumber, buildURL, err := client.WaitQueue(queueURL, 10*time.Minute, emit)
|
||||
if err != nil {
|
||||
emit("队列等待失败: " + err.Error())
|
||||
setRun("failed")
|
||||
return
|
||||
}
|
||||
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(map[string]any{
|
||||
"build_number": buildNumber, "external_run_id": fmt.Sprintf("#%d", buildNumber), "external_url": buildURL,
|
||||
})
|
||||
emit(fmt.Sprintf("构建 #%d 已开始: %s", buildNumber, buildURL))
|
||||
|
||||
client.StreamConsole(buildURL, emit)
|
||||
result, err := client.WaitResult(buildURL, 30*time.Minute)
|
||||
if err != nil {
|
||||
emit("获取构建结果超时: " + err.Error())
|
||||
setRun("failed")
|
||||
return
|
||||
}
|
||||
emit("Jenkins 结果: " + result)
|
||||
setRun(jenkins.MapResult(result))
|
||||
}
|
||||
|
||||
@@ -10,15 +10,38 @@ import (
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// ListPipelines GET /api/pipelines
|
||||
// ListPipelines GET /api/pipelines —— 附带每条流水线最近一次运行信息
|
||||
func ListPipelines(c *gin.Context) {
|
||||
var list []model.Pipeline
|
||||
database.DB.Order("updated_at DESC").Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
|
||||
// 每流水线最近一次 run(一次查询取全表后内存分组,数据量小;jianmu 用反规范化表,此处无需)
|
||||
var runs []model.PipelineRun
|
||||
database.DB.Order("started_at DESC").Limit(500).Find(&runs)
|
||||
lastByPipeline := make(map[uint]model.PipelineRun)
|
||||
for _, r := range runs {
|
||||
if _, ok := lastByPipeline[r.PipelineID]; !ok {
|
||||
lastByPipeline[r.PipelineID] = r
|
||||
}
|
||||
}
|
||||
|
||||
type pipelineWithLastRun struct {
|
||||
model.Pipeline
|
||||
LastRun *model.PipelineRun `json:"lastRun"`
|
||||
}
|
||||
result := make([]pipelineWithLastRun, 0, len(list))
|
||||
for _, p := range list {
|
||||
item := pipelineWithLastRun{Pipeline: p}
|
||||
if r, ok := lastByPipeline[p.ID]; ok {
|
||||
item.LastRun = &r
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GetPipeline GET /api/pipelines/:id —— 详情含画布节点与连线
|
||||
func GetPipeline(c *gin.Context) {
|
||||
// GetPipelineDetail GET /api/pipelines/:id —— 完整详情(编辑器各 Tab 数据源)
|
||||
func GetPipelineDetail(c *gin.Context) {
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
@@ -28,16 +51,73 @@ func GetPipeline(c *gin.Context) {
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Order("position_x").Find(&nodes)
|
||||
var edges []model.PipelineEdge
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||
var lastRun model.PipelineRun
|
||||
hasRun := database.DB.Where("pipeline_id = ?", p.ID).Order("started_at DESC").First(&lastRun).Error == nil
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pipeline": p, "nodes": nodes, "edges": edges,
|
||||
"name": p.Name, "description": p.Description, "type": p.Type,
|
||||
"sourceRepo": p.SourceRepo, "sourceBranch": p.SourceBranch,
|
||||
"gitopsRepo": p.GitopsRepo, "gitopsPath": p.GitopsPath,
|
||||
"deployType": p.DeployType, "env": p.Env,
|
||||
"id": p.ID, "createdAt": p.CreatedAt, "updatedAt": p.UpdatedAt,
|
||||
"lastRun": func() *model.PipelineRun {
|
||||
if hasRun {
|
||||
return &lastRun
|
||||
}
|
||||
return nil
|
||||
}(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetSummary GET /api/summary —— 侧边栏徽标与首页统计
|
||||
func GetSummary(c *gin.Context) {
|
||||
var buildNodes, plugins, running, pipelines, hosts, clusters int64
|
||||
database.DB.Model(&model.BuildNode{}).Count(&buildNodes)
|
||||
database.DB.Model(&model.Plugin{}).Count(&plugins)
|
||||
database.DB.Model(&model.PipelineRun{}).Where("status IN ('pending','queued','running')").Count(&running)
|
||||
database.DB.Model(&model.Pipeline{}).Count(&pipelines)
|
||||
database.DB.Model(&model.Host{}).Count(&hosts)
|
||||
database.DB.Model(&model.K8sCluster{}).Count(&clusters)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"buildNodes": buildNodes, "plugins": plugins, "running": running,
|
||||
"pipelines": pipelines, "hosts": hosts, "clusters": clusters,
|
||||
})
|
||||
}
|
||||
|
||||
// GetSettings GET /api/settings —— 单行设置
|
||||
func GetSettings(c *gin.Context) {
|
||||
var s model.SystemSetting
|
||||
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||
s = model.SystemSetting{ID: 1}
|
||||
database.DB.Create(&s)
|
||||
}
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// UpdateSettings PUT /api/settings
|
||||
func UpdateSettings(c *gin.Context) {
|
||||
var s model.SystemSetting
|
||||
if err := database.DB.First(&s, 1).Error; err != nil {
|
||||
s = model.SystemSetting{ID: 1}
|
||||
database.DB.Create(&s)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updates := map[string]any{"updated_at": time.Now()}
|
||||
if v, ok := body["theme"].(string); ok {
|
||||
updates["theme"] = v
|
||||
}
|
||||
if v, ok := body["notification"].(string); ok {
|
||||
updates["notification"] = v
|
||||
}
|
||||
if v, ok := body["toolMode"].(string); ok {
|
||||
updates["tool_mode"] = v
|
||||
}
|
||||
if v, ok := body["extra"].(string); ok {
|
||||
updates["extra"] = v
|
||||
}
|
||||
database.DB.Model(&s).Updates(updates)
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
// CreatePipeline POST /api/pipelines
|
||||
func CreatePipeline(c *gin.Context) {
|
||||
var input struct {
|
||||
@@ -82,7 +162,12 @@ func UpdatePipeline(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{"name": true, "description": true, "sourceRepo": true, "sourceBranch": true, "gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true}
|
||||
allowed := map[string]bool{
|
||||
"name": true, "description": true, "sourceRepo": true, "sourceBranch": true,
|
||||
"gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true,
|
||||
"triggerMode": true, "cronExpr": true, "webhookToken": true, "watchBranches": true,
|
||||
"variables": true, "cachePaths": true, "notifyWebhookURL": true, "jenkinsJobName": true,
|
||||
}
|
||||
updates := map[string]any{}
|
||||
for k, v := range input {
|
||||
if allowed[k] {
|
||||
|
||||
@@ -0,0 +1,755 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/crypto"
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// dialTCP 真实连通性测试:TCP 拨号 5 秒超时
|
||||
func dialTCP(addr string) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.Close()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 主机 /api/hosts
|
||||
// ============================================================
|
||||
|
||||
func ListHosts(c *gin.Context) {
|
||||
var list []model.Host
|
||||
q := database.DB.Order("updated_at DESC")
|
||||
if tag := c.Query("tag"); tag != "" {
|
||||
q = q.Where("tags LIKE ?", "%"+tag+"%")
|
||||
}
|
||||
q.Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func CreateHost(c *gin.Context) {
|
||||
var h model.Host
|
||||
if err := c.ShouldBindJSON(&h); err != nil || h.Name == "" || h.IP == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
|
||||
return
|
||||
}
|
||||
h.ID = 0
|
||||
if err := database.DB.Create(&h).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, h)
|
||||
}
|
||||
|
||||
func UpdateHost(c *gin.Context) {
|
||||
var h model.Host
|
||||
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
for _, k := range []string{"name", "ip", "port", "username", "tags", "os", "remark"} {
|
||||
if v, ok := body[k]; ok {
|
||||
database.DB.Model(&h).Update(k, v)
|
||||
}
|
||||
}
|
||||
database.DB.First(&h, h.ID)
|
||||
c.JSON(http.StatusOK, h)
|
||||
}
|
||||
|
||||
func DeleteHost(c *gin.Context) {
|
||||
database.DB.Delete(&model.Host{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// TestHost POST /api/hosts/:id/test —— 真实 TCP 拨号 SSH 端口
|
||||
func TestHost(c *gin.Context) {
|
||||
var h model.Host
|
||||
if err := database.DB.First(&h, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "host not found"})
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
err := dialTCP(fmt.Sprintf("%s:%d", h.IP, h.SSHPort))
|
||||
latency := time.Since(start).Milliseconds()
|
||||
status := "online"
|
||||
if err != nil {
|
||||
status = "offline"
|
||||
}
|
||||
database.DB.Model(&h).Update("status", status)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": err == nil, "status": status, "latencyMs": latency,
|
||||
"message": map[bool]string{true: fmt.Sprintf("SSH 端口可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 集群 /api/clusters
|
||||
// ============================================================
|
||||
|
||||
func ListClusters(c *gin.Context) {
|
||||
var list []model.K8sCluster
|
||||
database.DB.Order("updated_at DESC").Find(&list)
|
||||
for i := range list {
|
||||
list[i].KubeconfigSet = list[i].Kubeconfig != ""
|
||||
list[i].Kubeconfig = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func CreateCluster(c *gin.Context) {
|
||||
var raw map[string]any
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
name, _ := raw["name"].(string)
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "名称不能为空"})
|
||||
return
|
||||
}
|
||||
cl := model.K8sCluster{Name: name}
|
||||
cl.Context, _ = raw["context"].(string)
|
||||
cl.APIServer, _ = raw["apiServer"].(string)
|
||||
cl.Version, _ = raw["version"].(string)
|
||||
if v, ok := raw["nodes"].(float64); ok {
|
||||
cl.NodeCount = int(v)
|
||||
}
|
||||
if v, ok := raw["namespaces"].(string); ok {
|
||||
cl.Namespaces = v
|
||||
} else if ns, ok := raw["namespaces"].([]any); ok {
|
||||
if b, err := json.Marshal(ns); err == nil {
|
||||
cl.Namespaces = string(b)
|
||||
}
|
||||
}
|
||||
if kubeconfig, _ := raw["kubeconfig"].(string); kubeconfig != "" {
|
||||
enc, err := crypto.EncryptField(kubeconfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
|
||||
return
|
||||
}
|
||||
cl.Kubeconfig = enc
|
||||
}
|
||||
if err := database.DB.Create(&cl).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||
return
|
||||
}
|
||||
cl.KubeconfigSet = cl.Kubeconfig != ""
|
||||
cl.Kubeconfig = ""
|
||||
c.JSON(http.StatusOK, cl)
|
||||
}
|
||||
|
||||
func UpdateCluster(c *gin.Context) {
|
||||
var cl model.K8sCluster
|
||||
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
|
||||
return
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
for _, k := range []string{"name", "context", "apiServer", "version", "nodes", "namespaces"} {
|
||||
if v, ok := raw[k]; ok {
|
||||
updates[k] = v
|
||||
}
|
||||
}
|
||||
// kubeconfig 只写不读:非空才更新
|
||||
if kubeconfig, ok := raw["kubeconfig"].(string); ok && kubeconfig != "" {
|
||||
enc, err := crypto.EncryptField(kubeconfig)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "kubeconfig 加密失败"})
|
||||
return
|
||||
}
|
||||
updates["kubeconfig"] = enc
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
database.DB.Model(&cl).Updates(updates)
|
||||
}
|
||||
database.DB.First(&cl, cl.ID)
|
||||
cl.KubeconfigSet = cl.Kubeconfig != ""
|
||||
cl.Kubeconfig = ""
|
||||
c.JSON(http.StatusOK, cl)
|
||||
}
|
||||
|
||||
func DeleteCluster(c *gin.Context) {
|
||||
database.DB.Delete(&model.K8sCluster{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// TestCluster POST /api/clusters/:id/test —— 拨号 APIServer(或 kubeconfig 中的 server)
|
||||
func TestCluster(c *gin.Context) {
|
||||
var cl model.K8sCluster
|
||||
if err := database.DB.First(&cl, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
|
||||
return
|
||||
}
|
||||
target := cl.APIServer
|
||||
if cl.Kubeconfig != "" && crypto.IsEncrypted(cl.Kubeconfig) {
|
||||
if plain, err := crypto.DecryptField(cl.Kubeconfig); err == nil {
|
||||
for _, line := range strings.Split(plain, "\n") {
|
||||
if trimmed := strings.TrimSpace(line); strings.HasPrefix(trimmed, "server:") {
|
||||
target = strings.TrimSpace(strings.TrimPrefix(trimmed, "server:"))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if target == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "未配置 APIServer 或 kubeconfig"})
|
||||
return
|
||||
}
|
||||
host := strings.TrimPrefix(strings.TrimPrefix(target, "https://"), "http://")
|
||||
err := dialTCP(host)
|
||||
status := "connected"
|
||||
if err != nil {
|
||||
status = "disconnected"
|
||||
}
|
||||
now := time.Now()
|
||||
database.DB.Model(&cl).Updates(map[string]any{"status": status, "last_heartbeat": &now})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": err == nil, "status": status,
|
||||
"message": map[bool]string{true: "APIServer 可达", false: "连接失败: " + err.Error()}[err == nil],
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 凭证 /api/credentials(内容只写不读)
|
||||
// ============================================================
|
||||
|
||||
func ListCredentials(c *gin.Context) {
|
||||
var list []model.Credential
|
||||
q := database.DB.Order("updated_at DESC")
|
||||
if t := c.Query("type"); t != "" && t != "all" {
|
||||
q = q.Where("type = ?", t)
|
||||
}
|
||||
q.Find(&list)
|
||||
for i := range list {
|
||||
list[i].ContentSet = list[i].Content != ""
|
||||
list[i].Content = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func CreateCredential(c *gin.Context) {
|
||||
var raw map[string]any
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
name, _ := raw["name"].(string)
|
||||
typ, _ := raw["type"].(string)
|
||||
content, _ := raw["content"].(string)
|
||||
if name == "" || typ == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和类型不能为空"})
|
||||
return
|
||||
}
|
||||
cred := model.Credential{Name: name, Type: typ}
|
||||
if scope, ok := raw["scope"].(string); ok {
|
||||
cred.Scope = scope
|
||||
}
|
||||
if content != "" {
|
||||
enc, err := crypto.EncryptField(content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
|
||||
return
|
||||
}
|
||||
cred.Content = enc
|
||||
cred.Summary = summarizeCredential(typ, content)
|
||||
}
|
||||
if err := database.DB.Create(&cred).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||
return
|
||||
}
|
||||
cred.ContentSet = cred.Content != ""
|
||||
cred.Content = ""
|
||||
c.JSON(http.StatusOK, cred)
|
||||
}
|
||||
|
||||
// summarizeCredential 从明文 JSON 中提取脱敏摘要(不落明文)
|
||||
func summarizeCredential(typ, contentJSON string) string {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal([]byte(contentJSON), &m); err != nil {
|
||||
return typ + " 凭证"
|
||||
}
|
||||
pick := func(keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k].(string); ok && v != "" {
|
||||
return k + "=" + crypto.Mask(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
switch typ {
|
||||
case "ssh":
|
||||
return typ + " " + pick("ssh_user", "username", "private_key")
|
||||
case "docker":
|
||||
return typ + " " + pick("server") + " " + pick("username")
|
||||
case "git":
|
||||
return typ + " " + pick("url", "username")
|
||||
case "kubeconfig":
|
||||
return typ + " (kubeconfig)"
|
||||
case "aksk":
|
||||
return typ + " " + pick("access_key")
|
||||
default:
|
||||
if len(m) > 0 {
|
||||
return typ + " (" + fmt.Sprint(len(m)) + " 项)"
|
||||
}
|
||||
return typ + " 凭证"
|
||||
}
|
||||
}
|
||||
|
||||
func UpdateCredential(c *gin.Context) {
|
||||
var cred model.Credential
|
||||
if err := database.DB.First(&cred, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
|
||||
return
|
||||
}
|
||||
var raw map[string]any
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updates := map[string]any{}
|
||||
if v, ok := raw["name"].(string); ok && v != "" {
|
||||
updates["name"] = v
|
||||
}
|
||||
if v, ok := raw["scope"].(string); ok {
|
||||
updates["scope"] = v
|
||||
}
|
||||
if content, ok := raw["content"].(string); ok && content != "" {
|
||||
enc, err := crypto.EncryptField(content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
|
||||
return
|
||||
}
|
||||
updates["content"] = enc
|
||||
typ, _ := raw["type"].(string)
|
||||
if typ == "" {
|
||||
typ = cred.Type
|
||||
}
|
||||
updates["summary"] = summarizeCredential(typ, content)
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
database.DB.Model(&cred).Updates(updates)
|
||||
}
|
||||
database.DB.First(&cred, cred.ID)
|
||||
cred.ContentSet = cred.Content != ""
|
||||
cred.Content = ""
|
||||
c.JSON(http.StatusOK, cred)
|
||||
}
|
||||
|
||||
func DeleteCredential(c *gin.Context) {
|
||||
database.DB.Delete(&model.Credential{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 构建节点 /api/build-nodes
|
||||
// ============================================================
|
||||
|
||||
func ListBuildNodes(c *gin.Context) {
|
||||
var list []model.BuildNode
|
||||
database.DB.Order("updated_at DESC").Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func CreateBuildNode(c *gin.Context) {
|
||||
var n model.BuildNode
|
||||
if err := c.ShouldBindJSON(&n); err != nil || n.Name == "" || n.IP == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 IP 不能为空"})
|
||||
return
|
||||
}
|
||||
n.ID = 0
|
||||
if err := database.DB.Create(&n).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "创建失败:名称可能已存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, n)
|
||||
}
|
||||
|
||||
func UpdateBuildNode(c *gin.Context) {
|
||||
var n model.BuildNode
|
||||
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
|
||||
return
|
||||
}
|
||||
var raw map[string]any
|
||||
_ = c.ShouldBindJSON(&raw)
|
||||
for _, k := range []string{"name", "ip", "labels", "maxConcurrency", "workspacePath", "enabled"} {
|
||||
if v, ok := raw[k]; ok {
|
||||
database.DB.Model(&n).Update(k, v)
|
||||
}
|
||||
}
|
||||
database.DB.First(&n, n.ID)
|
||||
c.JSON(http.StatusOK, n)
|
||||
}
|
||||
|
||||
func DeleteBuildNode(c *gin.Context) {
|
||||
database.DB.Delete(&model.BuildNode{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// TestBuildNode POST /api/build-nodes/:id/test —— 真实 TCP 拨号
|
||||
func TestBuildNode(c *gin.Context) {
|
||||
var n model.BuildNode
|
||||
if err := database.DB.First(&n, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "build node not found"})
|
||||
return
|
||||
}
|
||||
start := time.Now()
|
||||
err := dialTCP(n.IP + ":22")
|
||||
latency := time.Since(start).Milliseconds()
|
||||
status := "online"
|
||||
if err != nil {
|
||||
status = "offline"
|
||||
}
|
||||
now := time.Now()
|
||||
database.DB.Model(&n).Updates(map[string]any{"status": status, "last_heartbeat": &now})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": err == nil, "status": status, "latencyMs": latency,
|
||||
"message": map[bool]string{true: fmt.Sprintf("节点可达 (%dms)", latency), false: "连接失败: " + err.Error()}[err == nil],
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具 /api/tools(真实检测 + 保守安装)
|
||||
// ============================================================
|
||||
|
||||
func ListTools(c *gin.Context) {
|
||||
var list []model.DeployTool
|
||||
database.DB.Order("sort_order").Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func UpdateTool(c *gin.Context) {
|
||||
var t model.DeployTool
|
||||
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
|
||||
return
|
||||
}
|
||||
var raw map[string]any
|
||||
_ = c.ShouldBindJSON(&raw)
|
||||
for _, k := range []string{"installMode", "version", "path", "enabled"} {
|
||||
if v, ok := raw[k]; ok {
|
||||
database.DB.Model(&t).Update(k, v)
|
||||
}
|
||||
}
|
||||
database.DB.First(&t, t.ID)
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
|
||||
// runToolCheck 真实执行 CheckCommand,返回输出中的版本号片段
|
||||
func runToolCheck(checkCommand string) (version string, out string, err error) {
|
||||
cmd := exec.Command("sh", "-c", checkCommand)
|
||||
cmd.Stderr = nil
|
||||
bytes, err := cmd.Output()
|
||||
out = strings.TrimSpace(string(bytes))
|
||||
if err != nil {
|
||||
// java -version 等把版本打到 stderr
|
||||
if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 {
|
||||
out = strings.TrimSpace(string(exitErr.Stderr))
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "version") {
|
||||
return extractVersion(line), out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", out, err
|
||||
}
|
||||
return extractVersion(out), out, nil
|
||||
}
|
||||
|
||||
// extractVersion 从命令输出中提取首个 x.y.z 形态的版本号
|
||||
func extractVersion(s string) string {
|
||||
fields := strings.Fields(s)
|
||||
inVersion := false
|
||||
for i, f := range fields {
|
||||
if strings.Contains(f, "version") {
|
||||
if i+1 < len(fields) {
|
||||
return strings.Trim(fields[i+1], `"`)
|
||||
}
|
||||
inVersion = true
|
||||
continue
|
||||
}
|
||||
if inVersion {
|
||||
return strings.Trim(f, `"`)
|
||||
}
|
||||
}
|
||||
// 兜底:找 x.y.z
|
||||
for _, f := range fields {
|
||||
if strings.Count(f, ".") >= 1 && f[0] >= '0' && f[0] <= '9' {
|
||||
return strings.Trim(f, `"`)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CheckTool POST /api/tools/:id/check —— 真实执行检测命令并回填版本
|
||||
func CheckTool(c *gin.Context) {
|
||||
var t model.DeployTool
|
||||
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
|
||||
return
|
||||
}
|
||||
version, out, err := runToolCheck(t.CheckCommand)
|
||||
now := time.Now()
|
||||
updates := map[string]any{"last_check": &now}
|
||||
if err == nil && version != "" {
|
||||
updates["status"] = "installed"
|
||||
updates["installed_version"] = version
|
||||
updates["error_msg"] = ""
|
||||
} else {
|
||||
updates["status"] = "not-installed"
|
||||
updates["error_msg"] = "未检测到:" + firstLine(out, err)
|
||||
}
|
||||
database.DB.Model(&t).Updates(updates)
|
||||
database.DB.First(&t, t.ID)
|
||||
c.JSON(http.StatusOK, gin.H{"tool": t, "output": out, "detected": version})
|
||||
}
|
||||
|
||||
// InstallTool POST /api/tools/:id/install —— 保守安装:真实检测,检出即装;未检出标记 failed 提示手动安装
|
||||
func InstallTool(c *gin.Context) {
|
||||
var t model.DeployTool
|
||||
if err := database.DB.First(&t, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "tool not found"})
|
||||
return
|
||||
}
|
||||
database.DB.Model(&t).Update("status", "installing")
|
||||
version, out, err := runToolCheck(t.CheckCommand)
|
||||
now := time.Now()
|
||||
updates := map[string]any{"last_check": &now}
|
||||
if err == nil && version != "" {
|
||||
updates["status"] = "installed"
|
||||
updates["installed_version"] = version
|
||||
updates["error_msg"] = ""
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "检测到已安装 " + version, "version": version, "output": out})
|
||||
} else {
|
||||
updates["status"] = "failed"
|
||||
updates["error_msg"] = "本机未检测到 " + t.DisplayName + ",保守安装策略不会自动下载,请手动安装(期望版本 " + t.Version + ")"
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": updates["error_msg"], "output": firstLine(out, err)})
|
||||
}
|
||||
database.DB.Model(&t).Updates(updates)
|
||||
}
|
||||
|
||||
func firstLine(out string, err error) string {
|
||||
if err != nil {
|
||||
return "命令执行失败: " + err.Error()
|
||||
}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
if l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Webhook /api/webhooks(HMAC-SHA256 签名)
|
||||
// ============================================================
|
||||
|
||||
func ListWebhooks(c *gin.Context) {
|
||||
var list []model.WebhookEndpoint
|
||||
database.DB.Order("updated_at DESC").Find(&list)
|
||||
for i := range list {
|
||||
list[i].SecretSet = list[i].Secret != ""
|
||||
list[i].Secret = ""
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func CreateWebhook(c *gin.Context) {
|
||||
var raw map[string]any
|
||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
name, _ := raw["name"].(string)
|
||||
url, _ := raw["url"].(string)
|
||||
if name == "" || url == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "名称和 URL 不能为空"})
|
||||
return
|
||||
}
|
||||
wh := model.WebhookEndpoint{Name: name, URL: url, Active: true}
|
||||
if events, ok := raw["events"]; ok {
|
||||
if b, err := json.Marshal(events); err == nil {
|
||||
wh.Events = string(b)
|
||||
}
|
||||
}
|
||||
if secret, ok := raw["secret"].(string); ok && secret != "" {
|
||||
enc, err := crypto.EncryptField(secret)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密失败"})
|
||||
return
|
||||
}
|
||||
wh.Secret = enc
|
||||
}
|
||||
if err := database.DB.Create(&wh).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
wh.SecretSet = wh.Secret != ""
|
||||
wh.Secret = ""
|
||||
c.JSON(http.StatusOK, wh)
|
||||
}
|
||||
|
||||
func UpdateWebhook(c *gin.Context) {
|
||||
var wh model.WebhookEndpoint
|
||||
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
|
||||
return
|
||||
}
|
||||
var raw map[string]any
|
||||
_ = c.ShouldBindJSON(&raw)
|
||||
updates := map[string]any{}
|
||||
if v, ok := raw["name"].(string); ok {
|
||||
updates["name"] = v
|
||||
}
|
||||
if v, ok := raw["url"].(string); ok {
|
||||
updates["url"] = v
|
||||
}
|
||||
if v, ok := raw["active"].(bool); ok {
|
||||
updates["active"] = v
|
||||
}
|
||||
if events, ok := raw["events"]; ok {
|
||||
if b, err := json.Marshal(events); err == nil {
|
||||
updates["events"] = string(b)
|
||||
}
|
||||
}
|
||||
if secret, ok := raw["secret"].(string); ok && secret != "" {
|
||||
if enc, err := crypto.EncryptField(secret); err == nil {
|
||||
updates["secret"] = enc
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
database.DB.Model(&wh).Updates(updates)
|
||||
}
|
||||
database.DB.First(&wh, wh.ID)
|
||||
wh.SecretSet = wh.Secret != ""
|
||||
wh.Secret = ""
|
||||
c.JSON(http.StatusOK, wh)
|
||||
}
|
||||
|
||||
func DeleteWebhook(c *gin.Context) {
|
||||
database.DB.Delete(&model.WebhookEndpoint{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// TestWebhook POST /api/webhooks/:id/test —— 真实 HTTP POST + HMAC-SHA256 签名
|
||||
func TestWebhook(c *gin.Context) {
|
||||
var wh model.WebhookEndpoint
|
||||
if err := database.DB.First(&wh, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "webhook not found"})
|
||||
return
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event": "webhook.test", "timestamp": time.Now().Format(time.RFC3339),
|
||||
"message": "FlowStack Webhook 测试消息",
|
||||
})
|
||||
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-FlowStack-Event", "webhook.test")
|
||||
if wh.Secret != "" {
|
||||
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(payload)
|
||||
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
now := time.Now()
|
||||
if err != nil {
|
||||
database.DB.Model(&wh).Updates(map[string]any{"last_status": "failed", "last_triggered_at": &now})
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "发送失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
status := "success"
|
||||
if resp.StatusCode >= 300 {
|
||||
status = "failed"
|
||||
}
|
||||
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
|
||||
c.JSON(http.StatusOK, gin.H{"success": resp.StatusCode < 300, "statusCode": resp.StatusCode, "message": fmt.Sprintf("响应 %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))})
|
||||
}
|
||||
|
||||
// NotifyWebhookEvent 流水线事件发生后通知订阅了该事件的 Webhook(异步调用)
|
||||
func NotifyWebhookEvent(event string, data map[string]any) {
|
||||
go func() {
|
||||
var endpoints []model.WebhookEndpoint
|
||||
database.DB.Where("active = ?", true).Find(&endpoints)
|
||||
payload, _ := json.Marshal(map[string]any{"event": event, "timestamp": time.Now().Format(time.RFC3339), "data": data})
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
for _, wh := range endpoints {
|
||||
if !strings.Contains(wh.Events, event) {
|
||||
continue
|
||||
}
|
||||
req, err := http.NewRequest("POST", wh.URL, strings.NewReader(string(payload)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-FlowStack-Event", event)
|
||||
if wh.Secret != "" {
|
||||
if secret, derr := crypto.DecryptField(wh.Secret); derr == nil && secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(payload)
|
||||
req.Header.Set("X-FlowStack-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
|
||||
}
|
||||
}
|
||||
status := "success"
|
||||
if resp, err := client.Do(req); err != nil || resp.StatusCode >= 300 {
|
||||
status = "failed"
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
database.DB.Model(&wh).Updates(map[string]any{"last_status": status, "last_triggered_at": &now})
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// InboundPipelineHook POST /api/hooks/pipeline/:id?token= —— 入站触发(TriggerPanel 的 Webhook URL)
|
||||
func InboundPipelineHook(hub *Hub) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
token := c.Query("token")
|
||||
if p.WebhookToken != "" && token != p.WebhookToken {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "token 校验失败"})
|
||||
return
|
||||
}
|
||||
if p.TriggerMode != "webhook" && p.TriggerMode != "git_push" {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "流水线未启用 Webhook 触发"})
|
||||
return
|
||||
}
|
||||
go triggerPipelineRun(hub, p, "", "", "webhook", "webhook")
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已触发流水线 " + p.Name})
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// CreateRun 创建运行并启动模拟执行引擎
|
||||
// CreateRun POST /api/pipelines/:id/runs —— 创建运行并启动执行引擎
|
||||
func CreateRun(hub *Hub) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var p model.Pipeline
|
||||
@@ -21,61 +21,82 @@ func CreateRun(hub *Hub) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Branch string `json:"branch"`
|
||||
Env string `json:"env"`
|
||||
TriggerBy string `json:"triggerBy"`
|
||||
Branch string `json:"branch"`
|
||||
Env string `json:"env"`
|
||||
TriggerBy string `json:"triggerBy"`
|
||||
TriggerType string `json:"triggerType"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if body.Branch == "" {
|
||||
body.Branch = p.SourceBranch
|
||||
}
|
||||
if body.Env == "" {
|
||||
body.Env = p.Env
|
||||
}
|
||||
if body.TriggerBy == "" {
|
||||
body.TriggerBy = "admin"
|
||||
}
|
||||
|
||||
var nodes []model.PipelineNode
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
|
||||
if len(nodes) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "流水线没有节点,请先在画布中编排并保存"})
|
||||
run, err := triggerPipelineRun(hub, p, body.Branch, body.Env, body.TriggerBy, body.TriggerType)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var edges []model.PipelineEdge
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||
nodesJSON, _ := json.Marshal(nodes)
|
||||
edgesJSON, _ := json.Marshal(edges)
|
||||
|
||||
run := model.PipelineRun{
|
||||
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
|
||||
PipelineID: p.ID, PipelineName: p.Name,
|
||||
Branch: body.Branch, Env: body.Env, DeployType: p.DeployType, Type: p.Type,
|
||||
TriggerBy: body.TriggerBy, TriggerType: "manual",
|
||||
Status: "running", StartedAt: time.Now(),
|
||||
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
|
||||
}
|
||||
if err := database.DB.Create(&run).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 按画布 X 坐标从左到右生成执行阶段
|
||||
sort.Slice(nodes, func(i, j int) bool { return nodes[i].PositionX < nodes[j].PositionX })
|
||||
stages := make([]model.RunStage, len(nodes))
|
||||
for i, n := range nodes {
|
||||
stages[i] = model.RunStage{RunID: run.ID, NodeID: n.NodeID, Name: n.Name, Status: "pending"}
|
||||
database.DB.Create(&stages[i])
|
||||
}
|
||||
|
||||
go simulateRun(hub, run, stages)
|
||||
|
||||
c.JSON(http.StatusOK, run)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerPipelineRun 统一的运行创建入口:校验节点 → 建 Run/Stages → 启动执行引擎
|
||||
func triggerPipelineRun(hub *Hub, p model.Pipeline, branch, env, triggerBy, triggerType string) (*model.PipelineRun, error) {
|
||||
if branch == "" {
|
||||
branch = p.SourceBranch
|
||||
}
|
||||
if env == "" {
|
||||
env = p.Env
|
||||
}
|
||||
if triggerBy == "" {
|
||||
triggerBy = "admin"
|
||||
}
|
||||
if triggerType == "" {
|
||||
triggerType = "manual"
|
||||
}
|
||||
|
||||
var nodes []model.PipelineNode
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
|
||||
if len(nodes) == 0 {
|
||||
return nil, fmt.Errorf("流水线没有节点,请先在画布中编排并保存")
|
||||
}
|
||||
var edges []model.PipelineEdge
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||
nodesJSON, _ := json.Marshal(nodes)
|
||||
edgesJSON, _ := json.Marshal(edges)
|
||||
|
||||
run := model.PipelineRun{
|
||||
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
|
||||
PipelineID: p.ID, PipelineName: p.Name,
|
||||
Branch: branch, Env: env, DeployType: p.DeployType, Type: p.Type,
|
||||
TriggerBy: triggerBy, TriggerType: triggerType,
|
||||
Status: "running", StartedAt: time.Now(),
|
||||
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
|
||||
}
|
||||
if err := database.DB.Create(&run).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// jenkins 类型流水线走真实 Jenkins 执行引擎
|
||||
if p.Type == "jenkins" && p.JenkinsJobName != "" {
|
||||
var server model.JenkinsServer
|
||||
if err := database.DB.First(&server, p.JenkinsServerID).Error; err == nil && server.Enabled {
|
||||
client := clientFromServer(server)
|
||||
go jenkinsRunEngine(hub, run, client, p.JenkinsJobName)
|
||||
return &run, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 按画布 X 坐标从左到右生成执行阶段
|
||||
sort.Slice(nodes, func(i, j int) bool { return nodes[i].PositionX < nodes[j].PositionX })
|
||||
stages := make([]model.RunStage, len(nodes))
|
||||
for i, n := range nodes {
|
||||
stages[i] = model.RunStage{RunID: run.ID, NodeID: n.NodeID, Name: n.Name, Status: "pending"}
|
||||
database.DB.Create(&stages[i])
|
||||
}
|
||||
|
||||
go simulateRun(hub, run, stages)
|
||||
return &run, nil
|
||||
}
|
||||
|
||||
// simulateRun 模拟执行:逐阶段 pending→running→success,逐条写日志并 SSE 推送
|
||||
func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
||||
start := time.Now()
|
||||
@@ -106,6 +127,13 @@ func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
||||
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
|
||||
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
||||
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
||||
// 通知订阅了 pipeline.success / pipeline.failed 的出站 Webhook
|
||||
if status == "success" || status == "failed" {
|
||||
NotifyWebhookEvent("pipeline."+status, map[string]any{
|
||||
"runId": run.RunID, "pipeline": run.PipelineName,
|
||||
"branch": run.Branch, "durationMs": time.Since(start).Milliseconds(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
|
||||
@@ -270,3 +298,25 @@ func StreamLogs(hub *Hub) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RetryRun POST /api/runs/:runId/retry —— 以同一流水线与分支重新触发
|
||||
func RetryRun(hub *Hub) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var prev model.PipelineRun
|
||||
if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&prev).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
|
||||
return
|
||||
}
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, prev.PipelineID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
run, err := triggerPipelineRun(hub, p, prev.Branch, prev.Env, "admin", "manual")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, run)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -15,18 +15,27 @@ type User struct {
|
||||
|
||||
// Pipeline 流水线定义
|
||||
type Pipeline struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"index" json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `gorm:"default:native" json:"type"` // native | jenkins | tekton
|
||||
SourceRepo string `json:"sourceRepo"`
|
||||
SourceBranch string `gorm:"default:main" json:"sourceBranch"`
|
||||
GitopsRepo string `json:"gitopsRepo"`
|
||||
GitopsPath string `json:"gitopsPath"`
|
||||
DeployType string `gorm:"default:host" json:"deployType"` // host | k8s | gitops
|
||||
Env string `gorm:"default:staging" json:"env"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"index" json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `gorm:"default:native" json:"type"` // native | jenkins | tekton
|
||||
SourceRepo string `json:"sourceRepo"`
|
||||
SourceBranch string `gorm:"default:main" json:"sourceBranch"`
|
||||
GitopsRepo string `json:"gitopsRepo"`
|
||||
GitopsPath string `json:"gitopsPath"`
|
||||
DeployType string `gorm:"default:host" json:"deployType"` // host | k8s | gitops
|
||||
Env string `gorm:"default:staging" json:"env"`
|
||||
TriggerMode string `gorm:"default:manual" json:"triggerMode"` // manual | git_push | cron | webhook
|
||||
CronExpr string `json:"cronExpr"`
|
||||
WebhookToken string `json:"webhookToken"`
|
||||
WatchBranches string `json:"watchBranches"`
|
||||
Variables string `gorm:"type:text" json:"variables"` // JSON [{key,value,remark}]
|
||||
CachePaths string `gorm:"type:text" json:"cachePaths"` // JSON [{path,key}]
|
||||
NotifyWebhook string `json:"notifyWebhookURL"`
|
||||
JenkinsJobName string `json:"jenkinsJobName"`
|
||||
JenkinsServerID uint `json:"jenkinsServerId"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PipelineNode 画布节点(JSON 字段名与前端 React Flow 对齐)
|
||||
@@ -67,8 +76,11 @@ type PipelineRun struct {
|
||||
Type string `gorm:"default:native" json:"type"`
|
||||
TriggerBy string `gorm:"default:system" json:"triggerBy"`
|
||||
TriggerType string `gorm:"default:manual" json:"triggerType"` // manual|webhook|cron|batch
|
||||
Status string `gorm:"default:pending" json:"status"` // pending|running|success|failed|cancelled
|
||||
Status string `gorm:"default:pending" json:"status"` // pending|queued|running|success|failed|cancelled
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
BuildNumber int `json:"buildNumber"`
|
||||
ExternalRunID string `json:"externalRunId"`
|
||||
ExternalURL string `json:"externalUrl"`
|
||||
NodesSnapshot string `json:"nodesSnapshot"`
|
||||
EdgesSnapshot string `json:"edgesSnapshot"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
@@ -103,19 +115,22 @@ type LogEntry struct {
|
||||
|
||||
// Application GitOps 应用
|
||||
type Application struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
Branch string `gorm:"default:main" json:"branch"`
|
||||
Path string `json:"path"`
|
||||
Cluster string `gorm:"default:default" json:"cluster"`
|
||||
Namespace string `gorm:"default:default" json:"namespace"`
|
||||
ManifestType string `gorm:"default:plain" json:"manifestType"` // helm|kustomize|plain
|
||||
SyncPolicy string `gorm:"default:manual" json:"syncPolicy"` // manual|automated
|
||||
AutoPrune bool `json:"autoPrune"`
|
||||
SelfHeal bool `json:"selfHeal"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
Branch string `gorm:"default:main" json:"branch"`
|
||||
Path string `json:"path"`
|
||||
Cluster string `gorm:"default:default" json:"cluster"`
|
||||
Namespace string `gorm:"default:default" json:"namespace"`
|
||||
ManifestType string `gorm:"default:plain" json:"manifestType"` // helm|kustomize|plain
|
||||
SyncPolicy string `gorm:"default:manual" json:"syncPolicy"` // manual|automated
|
||||
AutoPrune bool `json:"autoPrune"`
|
||||
SelfHeal bool `json:"selfHeal"`
|
||||
SyncStatus string `gorm:"default:Synced" json:"syncStatus"` // Synced|OutOfSync|Syncing
|
||||
HealthStatus string `gorm:"default:Healthy" json:"healthStatus"` // Healthy|Progressing|Degraded
|
||||
LastSyncAt *time.Time `json:"lastSyncAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Plugin 插件
|
||||
@@ -133,3 +148,125 @@ type Plugin struct {
|
||||
Config map[string]interface{} `gorm:"serializer:json" json:"config"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Host 主机资源
|
||||
type Host struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"`
|
||||
IP string `json:"ip"`
|
||||
SSHPort int `gorm:"default:22" json:"port"`
|
||||
Username string `gorm:"default:root" json:"username"`
|
||||
Tags string `gorm:"type:text" json:"tags"` // JSON ["production","web"]
|
||||
Status string `gorm:"default:offline" json:"status"` // online|offline
|
||||
OS string `json:"os"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// K8sCluster 集群资源
|
||||
type K8sCluster struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"`
|
||||
Context string `json:"context"`
|
||||
APIServer string `json:"apiServer"`
|
||||
Version string `json:"version"`
|
||||
Kubeconfig string `gorm:"type:text" json:"-"` // enc:v1: 加密,不回显
|
||||
KubeconfigSet bool `gorm:"-" json:"kubeconfigSet"`
|
||||
NodeCount int `json:"nodes"`
|
||||
Namespaces string `gorm:"type:text" json:"namespaces"` // JSON []
|
||||
Status string `gorm:"default:disconnected" json:"status"` // connected|disconnected
|
||||
LastHeartbeat *time.Time `json:"lastHeartbeat"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Credential 凭证(内容 AES-GCM 加密,只写不读)
|
||||
type Credential struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"`
|
||||
Type string `gorm:"index" json:"type"` // ssh|password|docker|git|kubeconfig|aksk
|
||||
Scope string `gorm:"default:全局" json:"scope"`
|
||||
Content string `gorm:"type:text" json:"-"` // enc:v1: 加密 JSON
|
||||
ContentSet bool `gorm:"-" json:"contentSet"`
|
||||
Summary string `json:"summary"` // 脱敏摘要
|
||||
UsageCount int `gorm:"default:0" json:"usageCount"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// BuildNode 构建节点
|
||||
type BuildNode struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"`
|
||||
IP string `json:"ip"`
|
||||
Labels string `gorm:"type:text" json:"labels"` // JSON ["docker","go"]
|
||||
MaxConcurrency int `gorm:"default:2" json:"maxConcurrency"`
|
||||
WorkspacePath string `gorm:"default:/data/flowstack-workspace" json:"workspacePath"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Status string `gorm:"default:offline" json:"status"` // online|offline|busy
|
||||
CurrentJobs int `gorm:"default:0" json:"currentJobs"`
|
||||
LastHeartbeat *time.Time `json:"lastHeartbeat"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// DeployTool 构建工具(检测命令真实执行;安装为保守策略:仅检测不下载)
|
||||
type DeployTool struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ToolKey string `gorm:"uniqueIndex" json:"key"`
|
||||
DisplayName string `json:"name"`
|
||||
Category string `gorm:"default:build" json:"category"` // build|deploy
|
||||
Status string `gorm:"default:not-installed" json:"status"` // not-installed|installed|installing|failed
|
||||
Version string `json:"version"` // 默认/期望版本
|
||||
InstalledVersion string `json:"installedVersion"` // 真实探测到的版本
|
||||
InstallMode string `gorm:"default:lazy" json:"installMode"` // preinstalled|lazy|disabled
|
||||
Path string `json:"path"`
|
||||
CheckCommand string `gorm:"type:text" json:"checkCommand"` // 真实版本探测命令
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
LastCheck *time.Time `json:"lastCheck"`
|
||||
ErrorMsg string `json:"errorMsg"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// WebhookEndpoint 出站 Webhook(HMAC-SHA256 签名)
|
||||
type WebhookEndpoint struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Secret string `gorm:"type:text" json:"-"` // enc:v1: 加密
|
||||
SecretSet bool `gorm:"-" json:"secretSet"`
|
||||
Events string `gorm:"type:text" json:"events"` // JSON ["pipeline.success",...]
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
LastTriggeredAt *time.Time `json:"lastTriggered"`
|
||||
LastStatus string `gorm:"default:none" json:"lastStatus"` // success|failed|none
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// JenkinsServer Jenkins 实例
|
||||
type JenkinsServer struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Username string `json:"username"`
|
||||
Token string `gorm:"type:text" json:"-"` // enc:v1: 加密
|
||||
TokenSet bool `gorm:"-" json:"tokenSet"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Remark string `json:"remark"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// SystemSetting 系统设置(单行,ID 恒为 1)
|
||||
type SystemSetting struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Theme string `gorm:"type:text" json:"theme"` // JSON {mode,primaryColor,...}
|
||||
Notification string `gorm:"type:text" json:"notification"` // JSON {email,browser,...}
|
||||
ToolMode string `gorm:"default:local" json:"toolMode"` // local|jenkins
|
||||
Extra string `gorm:"type:text" json:"extra"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
Reference in new issue
Block a user