feat: 发布权限 RBAC + PipelineEditor 修复 + 菜单优化
- 新增 DeployRBAC 页面: Jenkins-style Item Role 权限管理 - 正则匹配流水线 code,实时命中预览 - 内置用户分配面板,无需跳转系统管理 - 支持创建/编辑/删除/启用禁用 Item Role - 修复 PipelineEditor 成员管理 http 双解包 bug - 修复 PipelineManager 前端 code 路由迁移(delete/trigger/stop/loadRuns) - 新增批量删除流水线运行记录 API - Sidebar: 应用发布下新增「发布权限」, 仓库认证→发布配置, 移除用户组 - 恢复系统管理菜单(不受发布权限改动影响) - 后端 ItemRole CRUD handler + 正则匹配中间件已就绪
This commit is contained in:
1 parent
0fea0ccc20
commit
24f955832e
40 files changed
+7530
-1032
No files matched your search
@@ -1,8 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"ezops/internal/models"
|
||||
"ezops/pkg/crypto"
|
||||
@@ -11,150 +16,385 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CredentialHandler 凭据管理处理器 // Logan.Li
|
||||
// CredentialHandler 凭据管理处理器 v2(多类型动态字段)
|
||||
type CredentialHandler struct {
|
||||
db *gorm.DB
|
||||
crypt *crypto.SecretBox
|
||||
}
|
||||
|
||||
func NewCredentialHandler(db *gorm.DB, masterKey string) *CredentialHandler {
|
||||
return &CredentialHandler{
|
||||
db: db,
|
||||
crypt: crypto.NewSecretBox(masterKey),
|
||||
}
|
||||
return &CredentialHandler{db: db, crypt: crypto.NewSecretBox(masterKey)}
|
||||
}
|
||||
|
||||
// preview 内容脱敏预览:前8字符 + ... + 后4字符
|
||||
func preview(s string) string {
|
||||
if len(s) <= 12 {
|
||||
return "****"
|
||||
}
|
||||
return s[:8] + "..." + s[len(s)-4:]
|
||||
// ────── Helpers ──────
|
||||
|
||||
func genEnvPrefix() string {
|
||||
b := make([]byte, 4)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)[:8]
|
||||
}
|
||||
|
||||
// ListCredentials 凭据列表
|
||||
func buildContent(req models.CreateSecretRequest) (string, string, error) {
|
||||
switch req.SecretType {
|
||||
case "ssh":
|
||||
data := map[string]interface{}{
|
||||
"private_key": req.SSHPrivateKey,
|
||||
"public_key": req.SSHPublicKey,
|
||||
"ssh_user": req.SSHUser,
|
||||
"ssh_port": req.SSHPort,
|
||||
"password": req.SSHPassword,
|
||||
}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b), fmt.Sprintf("SSH %s@%d", req.SSHUser, req.SSHPort), nil
|
||||
case "docker":
|
||||
data := map[string]string{
|
||||
"server": req.DockerServer,
|
||||
"username": req.DockerUsername,
|
||||
"password": req.DockerPassword,
|
||||
}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b), req.DockerServer, nil
|
||||
case "tls":
|
||||
data := map[string]string{"cert": req.TLSCert, "key": req.TLSKey, "ca": req.TLSCA}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b), "TLS 证书已配置", nil
|
||||
case "aksk":
|
||||
data := map[string]string{
|
||||
"provider": req.AKSKProvider,
|
||||
"access_key": req.AKSKAccessKey,
|
||||
"secret_key": req.AKSKSecretKey,
|
||||
"region": req.AKSKRegion,
|
||||
}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b), req.AKSKProvider, nil
|
||||
case "database":
|
||||
data := map[string]interface{}{
|
||||
"db_type": req.DBType,
|
||||
"host": req.DBHost,
|
||||
"port": req.DBPort,
|
||||
"db_name": req.DBName,
|
||||
"username": req.DBUsername,
|
||||
"password": req.DBPassword,
|
||||
}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b), fmt.Sprintf("%s://%s:%d/%s", req.DBType, req.DBHost, req.DBPort, req.DBName), nil
|
||||
case "api":
|
||||
data := map[string]string{"api_key": req.APIKey, "endpoint": req.APIEndpoint}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b), req.APIEndpoint, nil
|
||||
case "generic":
|
||||
data := map[string]string{"generic_key": req.GenericKey, "generic_value": req.GenericValue}
|
||||
b, _ := json.Marshal(data)
|
||||
return string(b), req.GenericKey, nil
|
||||
}
|
||||
return "{}", "", fmt.Errorf("unknown type: %s", req.SecretType)
|
||||
}
|
||||
|
||||
// ────── List ──────
|
||||
|
||||
func (h *CredentialHandler) ListCredentials(c *gin.Context) {
|
||||
var creds []models.Credential
|
||||
if err := h.db.Order("updated_at DESC").Find(&creds).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "查询失败"})
|
||||
return
|
||||
var query models.SecretListQuery
|
||||
c.ShouldBindQuery(&query)
|
||||
|
||||
db := h.db.Model(&models.Credential{})
|
||||
if query.SecretType != "" {
|
||||
db = db.Where("secret_type = ?", query.SecretType)
|
||||
}
|
||||
items := make([]models.CredentialListResponse, 0, len(creds))
|
||||
if query.Search != "" {
|
||||
s := "%" + query.Search + "%"
|
||||
db = db.Where("name LIKE ? OR description LIKE ? OR variable_name LIKE ?", s, s, s)
|
||||
}
|
||||
if query.Tags != "" {
|
||||
for _, tag := range strings.Split(query.Tags, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
if tag != "" {
|
||||
db = db.Where("tags LIKE ?", "%\""+tag+"\"%")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
|
||||
page := query.Page
|
||||
if page < 1 { page = 1 }
|
||||
pageSize := query.PageSize
|
||||
if pageSize < 1 || pageSize > 100 { pageSize = 20 }
|
||||
|
||||
var creds []models.Credential
|
||||
db.Order("updated_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&creds)
|
||||
|
||||
items := make([]models.SecretListResponse, 0, len(creds))
|
||||
for _, cred := range creds {
|
||||
items = append(items, models.CredentialListResponse{
|
||||
ID: cred.ID,
|
||||
Name: cred.Name,
|
||||
Type: cred.Type,
|
||||
Preview: preview(cred.Content),
|
||||
Description: cred.Description,
|
||||
CreatedAt: cred.CreatedAt,
|
||||
UpdatedAt: cred.UpdatedAt,
|
||||
tags := []string{}
|
||||
if cred.Tags != "" {
|
||||
json.Unmarshal([]byte(cred.Tags), &tags)
|
||||
}
|
||||
items = append(items, models.SecretListResponse{
|
||||
ID: cred.ID,
|
||||
Name: cred.Name,
|
||||
VariableName: cred.VariableName,
|
||||
SecretType: cred.SecretType,
|
||||
Summary: cred.Summary,
|
||||
Tags: tags,
|
||||
Description: cred.Description,
|
||||
ExpiresAt: cred.ExpiresAt,
|
||||
IsActive: cred.IsActive,
|
||||
EnvPrefix: cred.EnvPrefix,
|
||||
CreatedAt: cred.CreatedAt,
|
||||
UpdatedAt: cred.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{
|
||||
List: items, Total: int64(len(items)),
|
||||
List: items, Total: total,
|
||||
}})
|
||||
}
|
||||
|
||||
// CreateCredential 创建凭据
|
||||
// ────── Create ──────
|
||||
|
||||
func (h *CredentialHandler) CreateCredential(c *gin.Context) {
|
||||
var req models.CredentialCreateRequest
|
||||
var req models.CreateSecretRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, err := h.crypt.Encrypt(req.Content)
|
||||
plainContent, summary, err := buildContent(req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, err := h.crypt.Encrypt(plainContent)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
tagsJSON, _ := json.Marshal(req.Tags)
|
||||
|
||||
isActive := true
|
||||
if req.IsActive != nil { isActive = *req.IsActive }
|
||||
|
||||
cred := models.Credential{
|
||||
Name: req.Name,
|
||||
Type: req.Type,
|
||||
Content: encrypted,
|
||||
Description: req.Description,
|
||||
Name: req.Name,
|
||||
VariableName: req.VariableName,
|
||||
SecretType: req.SecretType,
|
||||
Summary: summary,
|
||||
Content: encrypted,
|
||||
Tags: string(tagsJSON),
|
||||
Description: req.Description,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
IsActive: isActive,
|
||||
EnvPrefix: genEnvPrefix(),
|
||||
}
|
||||
|
||||
if err := h.db.Create(&cred).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"})
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Message: "创建成功",
|
||||
Data: models.CredentialListResponse{
|
||||
ID: cred.ID,
|
||||
Name: cred.Name,
|
||||
Type: cred.Type,
|
||||
Preview: preview(cred.Content),
|
||||
Description: cred.Description,
|
||||
CreatedAt: cred.CreatedAt,
|
||||
UpdatedAt: cred.UpdatedAt,
|
||||
},
|
||||
})
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: cred.ID})
|
||||
}
|
||||
|
||||
// UpdateCredential 更新凭据
|
||||
func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CredentialUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
// ────── Get Detail ──────
|
||||
|
||||
func (h *CredentialHandler) GetCredential(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
var cred models.Credential
|
||||
if h.db.First(&cred, id).Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
cred.Name = req.Name
|
||||
cred.Type = req.Type
|
||||
cred.Description = req.Description
|
||||
// Decrypt content
|
||||
plain, err := h.crypt.Decrypt(cred.Content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "解密失败"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Content != "" {
|
||||
encrypted, err := h.crypt.Encrypt(req.Content)
|
||||
var contentData map[string]interface{}
|
||||
json.Unmarshal([]byte(plain), &contentData)
|
||||
|
||||
tags := []string{}
|
||||
if cred.Tags != "" { json.Unmarshal([]byte(cred.Tags), &tags) }
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
|
||||
"id": cred.ID,
|
||||
"name": cred.Name,
|
||||
"variable_name": cred.VariableName,
|
||||
"secret_type": cred.SecretType,
|
||||
"content": contentData,
|
||||
"tags": tags,
|
||||
"description": cred.Description,
|
||||
"expires_at": cred.ExpiresAt,
|
||||
"is_active": cred.IsActive,
|
||||
"env_prefix": cred.EnvPrefix,
|
||||
"created_at": cred.CreatedAt,
|
||||
"updated_at": cred.UpdatedAt,
|
||||
}})
|
||||
}
|
||||
|
||||
// ────── Update ──────
|
||||
|
||||
func (h *CredentialHandler) UpdateCredential(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
var cred models.Credential
|
||||
if h.db.First(&cred, id).Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateSecretRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
|
||||
if req.Name != nil { updates["name"] = *req.Name }
|
||||
if req.VariableName != nil { updates["variable_name"] = *req.VariableName }
|
||||
if req.Description != nil { updates["description"] = *req.Description }
|
||||
if req.ExpiresAt != nil { updates["expires_at"] = req.ExpiresAt }
|
||||
if req.IsActive != nil { updates["is_active"] = *req.IsActive }
|
||||
|
||||
if len(req.Tags) > 0 {
|
||||
b, _ := json.Marshal(req.Tags)
|
||||
updates["tags"] = string(b)
|
||||
}
|
||||
|
||||
// Rebuild content if any type-specific field changed
|
||||
if hasTypeFieldUpdate(req) {
|
||||
// Decrypt old content
|
||||
oldPlain, _ := h.crypt.Decrypt(cred.Content)
|
||||
var old map[string]interface{}
|
||||
json.Unmarshal([]byte(oldPlain), &old)
|
||||
if old == nil { old = map[string]interface{}{} }
|
||||
|
||||
mergeUpdate(req, old)
|
||||
|
||||
newPlain, _ := json.Marshal(old)
|
||||
enc, err := h.crypt.Encrypt(string(newPlain))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "加密失败"})
|
||||
return
|
||||
}
|
||||
cred.Content = encrypted
|
||||
updates["content"] = enc
|
||||
|
||||
// Update summary
|
||||
updates["summary"] = buildSummaryFromRaw(old, cred.SecretType)
|
||||
}
|
||||
|
||||
if err := h.db.Save(&cred).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"})
|
||||
return
|
||||
if len(updates) > 0 {
|
||||
h.db.Model(&cred).Updates(updates)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功"})
|
||||
}
|
||||
|
||||
// DeleteCredential 删除凭据
|
||||
func hasTypeFieldUpdate(req models.UpdateSecretRequest) bool {
|
||||
return req.SSHPrivateKey != nil || req.SSHPublicKey != nil || req.SSHUser != nil || req.SSHPort != nil || req.SSHPassword != nil ||
|
||||
req.DockerServer != nil || req.DockerUsername != nil || req.DockerPassword != nil ||
|
||||
req.TLSCert != nil || req.TLSKey != nil || req.TLSCA != nil ||
|
||||
req.AKSKProvider != nil || req.AKSKAccessKey != nil || req.AKSKSecretKey != nil || req.AKSKRegion != nil ||
|
||||
req.DBType != nil || req.DBHost != nil || req.DBPort != nil || req.DBName != nil || req.DBUsername != nil || req.DBPassword != nil ||
|
||||
req.APIKey != nil || req.APIEndpoint != nil ||
|
||||
req.GenericKey != nil || req.GenericValue != nil
|
||||
}
|
||||
|
||||
func mergeUpdate(req models.UpdateSecretRequest, old map[string]interface{}) {
|
||||
if req.SSHPrivateKey != nil { old["private_key"] = *req.SSHPrivateKey }
|
||||
if req.SSHPublicKey != nil { old["public_key"] = *req.SSHPublicKey }
|
||||
if req.SSHUser != nil { old["ssh_user"] = *req.SSHUser }
|
||||
if req.SSHPort != nil { old["ssh_port"] = *req.SSHPort }
|
||||
if req.SSHPassword != nil { old["password"] = *req.SSHPassword }
|
||||
if req.DockerServer != nil { old["server"] = *req.DockerServer }
|
||||
if req.DockerUsername != nil { old["username"] = *req.DockerUsername }
|
||||
if req.DockerPassword != nil { old["password"] = *req.DockerPassword }
|
||||
if req.TLSCert != nil { old["cert"] = *req.TLSCert }
|
||||
if req.TLSKey != nil { old["key"] = *req.TLSKey }
|
||||
if req.TLSCA != nil { old["ca"] = *req.TLSCA }
|
||||
if req.AKSKProvider != nil { old["provider"] = *req.AKSKProvider }
|
||||
if req.AKSKAccessKey != nil { old["access_key"] = *req.AKSKAccessKey }
|
||||
if req.AKSKSecretKey != nil { old["secret_key"] = *req.AKSKSecretKey }
|
||||
if req.AKSKRegion != nil { old["region"] = *req.AKSKRegion }
|
||||
if req.DBType != nil { old["db_type"] = *req.DBType }
|
||||
if req.DBHost != nil { old["host"] = *req.DBHost }
|
||||
if req.DBPort != nil { old["port"] = *req.DBPort }
|
||||
if req.DBName != nil { old["db_name"] = *req.DBName }
|
||||
if req.DBUsername != nil { old["username"] = *req.DBUsername }
|
||||
if req.DBPassword != nil { old["password"] = *req.DBPassword }
|
||||
if req.APIKey != nil { old["api_key"] = *req.APIKey }
|
||||
if req.APIEndpoint != nil { old["endpoint"] = *req.APIEndpoint }
|
||||
if req.GenericKey != nil { old["generic_key"] = *req.GenericKey }
|
||||
if req.GenericValue != nil { old["generic_value"] = *req.GenericValue }
|
||||
}
|
||||
|
||||
func buildSummaryFromRaw(data map[string]interface{}, stype string) string {
|
||||
switch stype {
|
||||
case "ssh":
|
||||
return fmt.Sprintf("SSH %v@%v", data["ssh_user"], data["ssh_port"])
|
||||
case "docker":
|
||||
return fmt.Sprintf("%v", data["server"])
|
||||
case "aksk":
|
||||
return fmt.Sprintf("%v", data["provider"])
|
||||
case "database":
|
||||
return fmt.Sprintf("%v://%v:%v/%v", data["db_type"], data["host"], data["port"], data["db_name"])
|
||||
case "api":
|
||||
return fmt.Sprintf("%v", data["endpoint"])
|
||||
case "generic":
|
||||
return fmt.Sprintf("%v", data["generic_key"])
|
||||
case "tls":
|
||||
return "TLS 证书已配置"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ────── Patch (toggle is_active) ──────
|
||||
|
||||
func (h *CredentialHandler) PatchCredential(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
var body map[string]interface{}
|
||||
c.ShouldBindJSON(&body)
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if v, ok := body["is_active"]; ok {
|
||||
updates["is_active"] = v
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
h.db.Model(&models.Credential{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "ok"})
|
||||
}
|
||||
|
||||
// ────── Delete ──────
|
||||
|
||||
func (h *CredentialHandler) DeleteCredential(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var cred models.Credential
|
||||
if h.db.First(&cred, id).Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "凭据不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Delete(&cred).Error; err != nil {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if h.db.Delete(&models.Credential{}, id).Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
|
||||
}
|
||||
|
||||
// ────── Tags ──────
|
||||
|
||||
func (h *CredentialHandler) GetTags(c *gin.Context) {
|
||||
var rows []string
|
||||
h.db.Model(&models.Credential{}).Where("tags != ''").Pluck("tags", &rows)
|
||||
|
||||
tagSet := map[string]bool{}
|
||||
for _, r := range rows {
|
||||
var tags []string
|
||||
if json.Unmarshal([]byte(r), &tags) == nil {
|
||||
for _, t := range tags { tagSet[t] = true }
|
||||
}
|
||||
}
|
||||
tags := make([]string, 0, len(tagSet))
|
||||
for t := range tagSet { tags = append(tags, t) }
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: tags})
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func (h *CredentialHandler) AssignCredential(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if cred.Type == "ssh_key" {
|
||||
if cred.SecretType == "ssh" {
|
||||
server.SSHKey = plain
|
||||
server.AuthType = "key"
|
||||
server.Password = ""
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DeployHandler 发布系统处理器
|
||||
type DeployHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDeployHandler(db *gorm.DB) *DeployHandler {
|
||||
return &DeployHandler{db: db}
|
||||
}
|
||||
|
||||
// ==================== Git 仓库认证 ====================
|
||||
|
||||
func (h *DeployHandler) ListRepos(c *gin.Context) {
|
||||
var items []models.PipelineRepo
|
||||
h.db.Order("updated_at DESC").Find(&items)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}})
|
||||
}
|
||||
|
||||
func (h *DeployHandler) GetRepo(c *gin.Context) {
|
||||
var item models.PipelineRepo
|
||||
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
|
||||
}
|
||||
|
||||
func (h *DeployHandler) CreateRepo(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url" binding:"required"`
|
||||
AuthType models.RepoAuthType `json:"auth_type"`
|
||||
CredentialID *uint `json:"credential_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.AuthType == "" { req.AuthType = models.RepoAuthToken }
|
||||
item := models.PipelineRepo{
|
||||
Name: req.Name, Description: req.Description, URL: req.URL,
|
||||
AuthType: req.AuthType, CredentialID: req.CredentialID,
|
||||
CreatedBy: c.GetString("username"),
|
||||
}
|
||||
h.db.Create(&item)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
|
||||
}
|
||||
|
||||
func (h *DeployHandler) UpdateRepo(c *gin.Context) {
|
||||
var item models.PipelineRepo
|
||||
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
URL *string `json:"url"`
|
||||
AuthType *models.RepoAuthType `json:"auth_type"`
|
||||
CredentialID *uint `json:"credential_id"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != nil { updates["name"] = *req.Name }
|
||||
if req.Description != nil { updates["description"] = *req.Description }
|
||||
if req.URL != nil { updates["url"] = *req.URL }
|
||||
if req.AuthType != nil { updates["auth_type"] = *req.AuthType }
|
||||
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
|
||||
if req.CredentialID != nil { updates["credential_id"] = req.CredentialID }
|
||||
h.db.Model(&item).Updates(updates)
|
||||
h.db.First(&item, item.ID)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
|
||||
}
|
||||
|
||||
func (h *DeployHandler) DeleteRepo(c *gin.Context) {
|
||||
h.db.Delete(&models.PipelineRepo{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"})
|
||||
}
|
||||
|
||||
// ==================== 镜像仓库认证 ====================
|
||||
|
||||
func (h *DeployHandler) ListRegistries(c *gin.Context) {
|
||||
var items []models.PipelineRegistry
|
||||
h.db.Order("updated_at DESC").Find(&items)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}})
|
||||
}
|
||||
|
||||
func (h *DeployHandler) GetRegistry(c *gin.Context) {
|
||||
var item models.PipelineRegistry
|
||||
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
|
||||
}
|
||||
|
||||
func (h *DeployHandler) CreateRegistry(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
RegistryURL string `json:"registry_url" binding:"required"`
|
||||
Namespace string `json:"namespace"`
|
||||
AuthType models.RegistryAuthType `json:"auth_type"`
|
||||
CredentialID *uint `json:"credential_id"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.AuthType == "" { req.AuthType = models.RegistryAuthPassword }
|
||||
item := models.PipelineRegistry{
|
||||
Name: req.Name, Description: req.Description, RegistryURL: req.RegistryURL,
|
||||
Namespace: req.Namespace, AuthType: req.AuthType, CredentialID: req.CredentialID,
|
||||
CreatedBy: c.GetString("username"),
|
||||
}
|
||||
h.db.Create(&item)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
|
||||
}
|
||||
|
||||
func (h *DeployHandler) UpdateRegistry(c *gin.Context) {
|
||||
var item models.PipelineRegistry
|
||||
if err := h.db.First(&item, c.Param("id")); err.Error != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
RegistryURL *string `json:"registry_url"`
|
||||
Namespace *string `json:"namespace"`
|
||||
AuthType *models.RegistryAuthType `json:"auth_type"`
|
||||
CredentialID *uint `json:"credential_id"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != nil { updates["name"] = *req.Name }
|
||||
if req.Description != nil { updates["description"] = *req.Description }
|
||||
if req.RegistryURL != nil { updates["registry_url"] = *req.RegistryURL }
|
||||
if req.Namespace != nil { updates["namespace"] = *req.Namespace }
|
||||
if req.AuthType != nil { updates["auth_type"] = *req.AuthType }
|
||||
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
|
||||
if req.CredentialID != nil { updates["credential_id"] = req.CredentialID }
|
||||
h.db.Model(&item).Updates(updates)
|
||||
h.db.First(&item, item.ID)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
|
||||
}
|
||||
|
||||
func (h *DeployHandler) DeleteRegistry(c *gin.Context) {
|
||||
h.db.Delete(&models.PipelineRegistry{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ==================== 环境管理 ====================
|
||||
|
||||
// ListEnvironments 环境列表
|
||||
func (h *PipelineHandler) ListEnvironments(c *gin.Context) {
|
||||
var items []models.DeployEnvironment
|
||||
h.db.Order("sort_order ASC, id ASC").Find(&items)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: items, Total: int64(len(items))}})
|
||||
}
|
||||
|
||||
// CreateEnvironment 创建环境
|
||||
func (h *PipelineHandler) CreateEnvironment(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
DisplayName string `json:"display_name" binding:"required"`
|
||||
Type string `json:"type" binding:"required,oneof=production staging testing development"`
|
||||
Color string `json:"color"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Color == "" { req.Color = typeDefaultColor(req.Type) }
|
||||
item := models.DeployEnvironment{
|
||||
Name: req.Name, DisplayName: req.DisplayName,
|
||||
Type: models.EnvironmentType(req.Type), Color: req.Color,
|
||||
SortOrder: req.SortOrder, Description: req.Description,
|
||||
}
|
||||
if err := h.db.Create(&item).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
|
||||
}
|
||||
|
||||
// UpdateEnvironment 更新环境
|
||||
func (h *PipelineHandler) UpdateEnvironment(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var item models.DeployEnvironment
|
||||
if err := h.db.First(&item, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
DisplayName *string `json:"display_name"`
|
||||
Type *string `json:"type"`
|
||||
Color *string `json:"color"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
Description *string `json:"description"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != nil { updates["name"] = *req.Name }
|
||||
if req.DisplayName != nil { updates["display_name"] = *req.DisplayName }
|
||||
if req.Type != nil { updates["type"] = *req.Type }
|
||||
if req.Color != nil { updates["color"] = *req.Color }
|
||||
if req.SortOrder != nil { updates["sort_order"] = *req.SortOrder }
|
||||
if req.Description != nil { updates["description"] = *req.Description }
|
||||
if req.Enabled != nil { updates["enabled"] = *req.Enabled }
|
||||
h.db.Model(&item).Updates(updates)
|
||||
h.db.First(&item, id)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: item})
|
||||
}
|
||||
|
||||
// DeleteEnvironment 删除环境
|
||||
func (h *PipelineHandler) DeleteEnvironment(c *gin.Context) {
|
||||
h.db.Delete(&models.DeployEnvironment{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"})
|
||||
}
|
||||
|
||||
func typeDefaultColor(t string) string {
|
||||
switch t {
|
||||
case "production": return "#EF4444"
|
||||
case "staging": return "#F59E0B"
|
||||
case "testing": return "#10B981"
|
||||
default: return "#3B82F6" // development
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 流水线成员管理 ====================
|
||||
|
||||
// ListPipelineMembers 成员列表
|
||||
func (h *PipelineHandler) ListPipelineMembers(c *gin.Context) {
|
||||
pipelineID := getPipelineID(c)
|
||||
var members []models.DeployPipelineMember
|
||||
h.db.Where("pipeline_id = ?", pipelineID).Order("created_at ASC").Find(&members)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: members})
|
||||
}
|
||||
|
||||
// AddPipelineMember 添加成员
|
||||
func (h *PipelineHandler) AddPipelineMember(c *gin.Context) {
|
||||
pipelineID := getPipelineID(c)
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Role string `json:"role" binding:"required,oneof=owner editor viewer"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var pipeline models.Pipeline
|
||||
if err := h.db.First(&pipeline, pipelineID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
member := models.DeployPipelineMember{
|
||||
PipelineID: uint(pipelineID),
|
||||
Username: &req.Username,
|
||||
Role: models.MemberRole(req.Role),
|
||||
}
|
||||
if err := h.db.Create(&member).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "该用户已是成员"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: member})
|
||||
}
|
||||
|
||||
// RemovePipelineMember 移除成员
|
||||
func (h *PipelineHandler) RemovePipelineMember(c *gin.Context) {
|
||||
pipelineID := getPipelineID(c)
|
||||
memberID, _ := strconv.Atoi(c.Param("memberId"))
|
||||
result := h.db.Where("id = ? AND pipeline_id = ?", memberID, pipelineID).Delete(&models.DeployPipelineMember{})
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "成员不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已移除"})
|
||||
}
|
||||
|
||||
// ==================== 流水线运行控制 ====================
|
||||
|
||||
// StopPipelineRun 停止运行中的流水线
|
||||
func (h *PipelineHandler) StopPipelineRun(c *gin.Context) {
|
||||
pipelineID := getPipelineID(c)
|
||||
runID, _ := strconv.Atoi(c.Param("runId"))
|
||||
|
||||
var run models.PipelineRun
|
||||
if err := h.db.Where("id = ? AND pipeline_id = ?", runID, pipelineID).First(&run).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "运行记录不存在"})
|
||||
return
|
||||
}
|
||||
if run.Status != "running" && run.Status != "pending" {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "仅运行中或等待中的任务可停止, 当前状态: " + run.Status})
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"status": "cancelled",
|
||||
"finished_at": &now,
|
||||
}
|
||||
if run.StartedAt != nil {
|
||||
dur := int(now.Sub(*run.StartedAt).Seconds())
|
||||
updates["duration"] = dur
|
||||
}
|
||||
h.db.Model(&run).Updates(updates)
|
||||
|
||||
// 追加日志
|
||||
logEntry := map[string]string{
|
||||
"time": now.Format("15:04:05"),
|
||||
"level": "warn",
|
||||
"message": ">> 任务已被手动停止",
|
||||
"stage": "--",
|
||||
}
|
||||
existingLogs := run.Logs
|
||||
if existingLogs == "" {
|
||||
existingLogs = "[]"
|
||||
}
|
||||
var logEntries []map[string]string
|
||||
json.Unmarshal([]byte(existingLogs), &logEntries)
|
||||
logEntries = append(logEntries, logEntry)
|
||||
newLogs, _ := json.Marshal(logEntries)
|
||||
h.db.Model(&run).Update("logs", string(newLogs))
|
||||
|
||||
h.db.First(&run, runID)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: run, Message: "已停止"})
|
||||
}
|
||||
|
||||
// GetRunLog 获取运行日志
|
||||
func (h *PipelineHandler) GetRunLog(c *gin.Context) {
|
||||
pipelineID := getPipelineID(c)
|
||||
runID, _ := strconv.Atoi(c.Param("runId"))
|
||||
|
||||
var run models.PipelineRun
|
||||
if err := h.db.Where("id = ? AND pipeline_id = ?", runID, pipelineID).First(&run).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "运行记录不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var logs []map[string]string
|
||||
if run.Logs != "" {
|
||||
json.Unmarshal([]byte(run.Logs), &logs)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: gin.H{
|
||||
"run": run,
|
||||
"logs": logs,
|
||||
}})
|
||||
}
|
||||
|
||||
// BatchDeleteRuns 批量删除运行记录
|
||||
func (h *PipelineHandler) BatchDeleteRuns(c *gin.Context) {
|
||||
pipelineID := getPipelineID(c)
|
||||
|
||||
var req struct {
|
||||
IDs []uint `json:"ids" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请选择要删除的记录"})
|
||||
return
|
||||
}
|
||||
|
||||
result := h.db.Where("pipeline_id = ? AND id IN ?", pipelineID, req.IDs).Delete(&models.PipelineRun{})
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: fmt.Sprintf("已删除 %d 条记录", result.RowsAffected)})
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ==================== 用户组管理 ====================
|
||||
|
||||
// ListGroups 用户组列表
|
||||
func (h *PipelineHandler) ListGroups(c *gin.Context) {
|
||||
var groups []models.DeployUserGroup
|
||||
h.db.Preload("Members").Order("created_at DESC").Find(&groups)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: groups, Total: int64(len(groups))}})
|
||||
}
|
||||
|
||||
// CreateGroup 创建用户组
|
||||
func (h *PipelineHandler) CreateGroup(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
group := models.DeployUserGroup{
|
||||
Name: req.Name, Description: req.Description,
|
||||
CreatedBy: c.GetString("username"),
|
||||
}
|
||||
if err := h.db.Create(&group).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "组名已存在: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: group})
|
||||
}
|
||||
|
||||
// UpdateGroup 更新用户组
|
||||
func (h *PipelineHandler) UpdateGroup(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
var group models.DeployUserGroup
|
||||
if err := h.db.First(&group, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
c.ShouldBindJSON(&req)
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != nil { updates["name"] = *req.Name }
|
||||
if req.Description != nil { updates["description"] = *req.Description }
|
||||
h.db.Model(&group).Updates(updates)
|
||||
h.db.First(&group, id)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: group})
|
||||
}
|
||||
|
||||
// DeleteGroup 删除用户组
|
||||
func (h *PipelineHandler) DeleteGroup(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
// 先删组成员和流水线授权
|
||||
h.db.Where("group_id = ?", id).Delete(&models.DeployUserGroupMember{})
|
||||
h.db.Where("group_id = ?", id).Delete(&models.DeployPipelineMember{})
|
||||
h.db.Delete(&models.DeployUserGroup{}, id)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已删除"})
|
||||
}
|
||||
|
||||
// ==================== 用户组成员管理 ====================
|
||||
|
||||
// ListGroupMembers 组成员列表
|
||||
func (h *PipelineHandler) ListGroupMembers(c *gin.Context) {
|
||||
groupID, _ := strconv.Atoi(c.Param("id"))
|
||||
var members []models.DeployUserGroupMember
|
||||
h.db.Where("group_id = ?", groupID).Find(&members)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: members})
|
||||
}
|
||||
|
||||
// AddGroupMember 添加组成员
|
||||
func (h *PipelineHandler) AddGroupMember(c *gin.Context) {
|
||||
groupID, _ := strconv.Atoi(c.Param("id"))
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
member := models.DeployUserGroupMember{GroupID: uint(groupID), Username: req.Username}
|
||||
if err := h.db.Create(&member).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "该用户已在组内"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: member})
|
||||
}
|
||||
|
||||
// RemoveGroupMember 移除组成员
|
||||
func (h *PipelineHandler) RemoveGroupMember(c *gin.Context) {
|
||||
groupID, _ := strconv.Atoi(c.Param("id"))
|
||||
memberID, _ := strconv.Atoi(c.Param("memberId"))
|
||||
result := h.db.Where("id = ? AND group_id = ?", memberID, groupID).Delete(&models.DeployUserGroupMember{})
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "成员不存在"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已移除"})
|
||||
}
|
||||
|
||||
// ==================== 组授权给流水线 ====================
|
||||
|
||||
// AssignGroupToPipeline 将用户组授权到流水线
|
||||
func (h *PipelineHandler) AssignGroupToPipeline(c *gin.Context) {
|
||||
pipelineID := getPipelineID(c)
|
||||
var req struct {
|
||||
GroupID uint `json:"group_id" binding:"required"`
|
||||
Role string `json:"role" binding:"required,oneof=owner editor viewer"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var pipeline models.Pipeline
|
||||
if err := h.db.First(&pipeline, pipelineID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在"})
|
||||
return
|
||||
}
|
||||
var group models.DeployUserGroup
|
||||
if err := h.db.First(&group, req.GroupID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "用户组不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
member := models.DeployPipelineMember{
|
||||
PipelineID: uint(pipelineID),
|
||||
GroupID: &req.GroupID,
|
||||
Role: models.MemberRole(req.Role),
|
||||
}
|
||||
if err := h.db.Create(&member).Error; err != nil {
|
||||
c.JSON(http.StatusConflict, models.Response{Code: 409, Message: "该组已被授权到此流水线"})
|
||||
return
|
||||
}
|
||||
|
||||
// 预加载关联
|
||||
member.Group = &group
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: member, Message: "已授权"})
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ItemRoleHandler Item Role handler (Jenkins-style regex pipeline matching)
|
||||
type ItemRoleHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewItemRoleHandler creates a new ItemRoleHandler
|
||||
func NewItemRoleHandler(db *gorm.DB) *ItemRoleHandler {
|
||||
return &ItemRoleHandler{db: db}
|
||||
}
|
||||
|
||||
// ListItemRoles GET /admin/item-roles
|
||||
func (h *ItemRoleHandler) ListItemRoles(c *gin.Context) {
|
||||
var roles []models.ItemRole
|
||||
h.db.Order("created_at DESC").Find(&roles)
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Data: models.ListResponse{List: roles, Total: int64(len(roles))},
|
||||
})
|
||||
}
|
||||
|
||||
// CreateItemRole POST /admin/item-roles
|
||||
func (h *ItemRoleHandler) CreateItemRole(c *gin.Context) {
|
||||
var req models.CreateItemRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 唯一性校验
|
||||
var existing models.ItemRole
|
||||
if err := h.db.Where("name = ? OR code = ?", req.Name, req.Code).First(&existing).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "Item Role 名称或编码已存在"})
|
||||
return
|
||||
}
|
||||
|
||||
role := models.ItemRole{
|
||||
Name: req.Name,
|
||||
Code: req.Code,
|
||||
Pattern: req.Pattern,
|
||||
Role: req.Role,
|
||||
Description: req.Description,
|
||||
Status: 1,
|
||||
}
|
||||
if err := h.db.Create(&role).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "创建失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "创建成功", Data: role})
|
||||
}
|
||||
|
||||
// UpdateItemRole PUT /admin/item-roles/:id
|
||||
func (h *ItemRoleHandler) UpdateItemRole(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.UpdateItemRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var role models.ItemRole
|
||||
if err := h.db.First(&role, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Item Role 不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != "" {
|
||||
updates["name"] = req.Name
|
||||
}
|
||||
if req.Code != "" {
|
||||
updates["code"] = req.Code
|
||||
}
|
||||
if req.Pattern != "" {
|
||||
updates["pattern"] = req.Pattern
|
||||
}
|
||||
if req.Role != "" {
|
||||
updates["role"] = req.Role
|
||||
}
|
||||
if req.Description != "" {
|
||||
updates["description"] = req.Description
|
||||
}
|
||||
if req.Status != nil {
|
||||
updates["status"] = *req.Status
|
||||
}
|
||||
|
||||
if len(updates) > 0 {
|
||||
if err := h.db.Model(&role).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
h.db.First(&role, id)
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "更新成功", Data: role})
|
||||
}
|
||||
|
||||
// DeleteItemRole DELETE /admin/item-roles/:id
|
||||
func (h *ItemRoleHandler) DeleteItemRole(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
// Clean up user associations first
|
||||
h.db.Where("item_role_id = ?", id).Delete(&models.UserItemRole{})
|
||||
if err := h.db.Delete(&models.ItemRole{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "删除成功"})
|
||||
}
|
||||
|
||||
// ListUserItemRoles GET /admin/users/:id/item-roles
|
||||
func (h *ItemRoleHandler) ListUserItemRoles(c *gin.Context) {
|
||||
userID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var assignments []models.UserItemRole
|
||||
h.db.Preload("ItemRole").Where("user_id = ?", userID).Find(&assignments)
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Data: models.ListResponse{List: assignments, Total: int64(len(assignments))},
|
||||
})
|
||||
}
|
||||
|
||||
// AssignItemRole POST /admin/users/:id/item-roles
|
||||
func (h *ItemRoleHandler) AssignItemRole(c *gin.Context) {
|
||||
userID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.AssignItemRoleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "请求参数错误: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already assigned
|
||||
var existing models.UserItemRole
|
||||
if err := h.db.Where("user_id = ? AND item_role_id = ?", userID, req.ItemRoleID).First(&existing).Error; err == nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "该用户已拥有此 Item Role"})
|
||||
return
|
||||
}
|
||||
|
||||
assignment := models.UserItemRole{
|
||||
UserID: uint(userID),
|
||||
ItemRoleID: req.ItemRoleID,
|
||||
}
|
||||
if err := h.db.Create(&assignment).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "分配失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "分配成功", Data: assignment})
|
||||
}
|
||||
|
||||
// RemoveItemRole DELETE /admin/users/:id/item-roles/:roleId
|
||||
func (h *ItemRoleHandler) RemoveItemRole(c *gin.Context) {
|
||||
userID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
roleID, err := strconv.Atoi(c.Param("roleId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的 Role ID"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.Where("user_id = ? AND item_role_id = ?", uint(userID), roleID).Delete(&models.UserItemRole{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "移除失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "移除成功"})
|
||||
}
|
||||
@@ -2,22 +2,63 @@ package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
"ezops/internal/api/middleware"
|
||||
ezredis "ezops/pkg/redis"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PipelineHandler 流水线处理器(流水线 + 应用发布两个子域,应用发布方法拆分至 pipeline_release.go) // Logan.Li
|
||||
// getPipelineID 从 context 获取中间件注入的流水线 ID
|
||||
func getPipelineID(c *gin.Context) uint {
|
||||
if v, exists := c.Get("pipelineID"); exists {
|
||||
if id, ok := v.(uint); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// codePattern 流水线编码规则:字母+数字+连字符,不能以连字符开头/结尾
|
||||
var codePattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
// validateCode 校验流水线编码格式并查重
|
||||
func validateCode(db *gorm.DB, code string, excludeID uint) string {
|
||||
if code == "" {
|
||||
return "流水线编码不能为空"
|
||||
}
|
||||
if len(code) > 64 {
|
||||
return "编码长度不能超过 64 字符"
|
||||
}
|
||||
if !codePattern.MatchString(code) {
|
||||
return "编码格式不正确:只能使用字母、数字、连字符(-),不能以连字符开头或结尾\n示例: My-Pipeline, demo-jar-backend"
|
||||
}
|
||||
// 查重
|
||||
var count int64
|
||||
q := db.Model(&models.Pipeline{}).Where("code = ?", code)
|
||||
if excludeID > 0 {
|
||||
q = q.Where("id != ?", excludeID)
|
||||
}
|
||||
q.Count(&count)
|
||||
if count > 0 {
|
||||
return "编码已存在,请使用其他编码"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// PipelineHandler 流水线管理器
|
||||
type PipelineHandler struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
cache ezredis.Cache
|
||||
}
|
||||
|
||||
// NewPipelineHandler 创建流水线处理器
|
||||
func NewPipelineHandler(db *gorm.DB) *PipelineHandler {
|
||||
return &PipelineHandler{db: db}
|
||||
func NewPipelineHandler(db *gorm.DB, cache ezredis.Cache) *PipelineHandler {
|
||||
return &PipelineHandler{db: db, cache: cache}
|
||||
}
|
||||
|
||||
// ListPipelines 获取流水线列表
|
||||
@@ -29,24 +70,38 @@ func NewPipelineHandler(db *gorm.DB) *PipelineHandler {
|
||||
// @Param page query int false "页码"
|
||||
// @Param page_size query int false "每页数量"
|
||||
// @Success 200 {object} models.Response{data=models.ListResponse}
|
||||
// @Router /pipelines [get]
|
||||
// ListPipelines 获取流水线列表
|
||||
func (h *PipelineHandler) ListPipelines(c *gin.Context) {
|
||||
var query models.PageQuery
|
||||
c.ShouldBindQuery(&query)
|
||||
|
||||
// 管理员看全部,非管理员只看自己有权限的
|
||||
isAdmin, _ := c.Get("isAdmin")
|
||||
username, _ := c.Get("username")
|
||||
usernameStr, _ := username.(string)
|
||||
userID, _ := c.Get("userID")
|
||||
uid, _ := userID.(uint)
|
||||
|
||||
db := h.db.Model(&models.Pipeline{})
|
||||
if isAdmin != true && usernameStr != "" {
|
||||
ids, hasAccess := middleware.GetAccessiblePipelineIDs(h.db, usernameStr, uid)
|
||||
if !hasAccess {
|
||||
// 没有任何流水线权限 → 返回空列表
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Data: models.ListResponse{List: []models.Pipeline{}, Total: 0}})
|
||||
return
|
||||
}
|
||||
db = db.Where("id IN ?", ids)
|
||||
}
|
||||
|
||||
var total int64
|
||||
h.db.Model(&models.Pipeline{}).Count(&total)
|
||||
db.Count(&total)
|
||||
|
||||
var pipelines []models.Pipeline
|
||||
h.db.Offset(query.GetOffset()).Limit(query.GetLimit()).Find(&pipelines)
|
||||
db.Preload("Environment").Offset(query.GetOffset()).Limit(query.GetLimit()).Order("updated_at DESC").Find(&pipelines)
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Message: "success",
|
||||
Data: models.ListResponse{
|
||||
List: pipelines,
|
||||
Total: total,
|
||||
},
|
||||
Data: models.ListResponse{List: pipelines, Total: total},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,7 +115,11 @@ func (h *PipelineHandler) ListPipelines(c *gin.Context) {
|
||||
// @Success 200 {object} models.Response{data=models.Pipeline}
|
||||
// @Router /pipelines/{id} [get]
|
||||
func (h *PipelineHandler) GetPipeline(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
id := getPipelineID(c)
|
||||
if id == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"})
|
||||
return
|
||||
}
|
||||
|
||||
var pipeline models.Pipeline
|
||||
if err := h.db.First(&pipeline, id).Error; err != nil {
|
||||
@@ -98,17 +157,42 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 校验编码格式
|
||||
if msg := validateCode(h.db, req.Code, 0); msg != "" {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: msg})
|
||||
return
|
||||
}
|
||||
|
||||
pipeline := models.Pipeline{
|
||||
Name: req.Name,
|
||||
Code: req.Code,
|
||||
Description: req.Description,
|
||||
Type: req.Type,
|
||||
Source: req.Source,
|
||||
Branch: req.Branch,
|
||||
Dockerfile: req.Dockerfile,
|
||||
Stages: req.Stages,
|
||||
Variables: req.Variables,
|
||||
Enabled: true,
|
||||
Name: req.Name,
|
||||
Code: req.Code,
|
||||
JobName: req.JobName,
|
||||
Description: req.Description,
|
||||
Type: req.Type,
|
||||
Source: req.Source,
|
||||
Branch: req.Branch,
|
||||
Dockerfile: req.Dockerfile,
|
||||
Stages: req.Stages,
|
||||
Variables: req.Variables,
|
||||
DeployMode: req.DeployMode,
|
||||
DeployTarget: req.DeployTarget,
|
||||
ServerIDs: req.ServerIDs,
|
||||
K8sClusterID: req.K8sClusterID,
|
||||
K8sNamespace: req.K8sNamespace,
|
||||
RepoID: req.RepoID,
|
||||
RegistryID: req.RegistryID,
|
||||
GitCredentialID: req.GitCredentialID,
|
||||
RegistryCredentialID: req.RegistryCredentialID,
|
||||
EnvironmentID: req.EnvironmentID,
|
||||
// 触发配置
|
||||
TriggerMode: req.TriggerMode,
|
||||
WatchBranches: req.WatchBranches,
|
||||
ExcludeBranch: req.ExcludeBranch,
|
||||
CronExpr: req.CronExpr,
|
||||
WebhookToken: req.WebhookToken,
|
||||
// 缓存
|
||||
CachePaths: req.CachePaths,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&pipeline).Error; err != nil {
|
||||
@@ -138,9 +222,13 @@ func (h *PipelineHandler) CreatePipeline(c *gin.Context) {
|
||||
// @Success 200 {object} models.Response{data=models.Pipeline}
|
||||
// @Router /pipelines/{id} [put]
|
||||
func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
id := getPipelineID(c)
|
||||
if id == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.CreatePipelineRequest
|
||||
var req models.UpdatePipelineRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, models.Response{
|
||||
Code: 400,
|
||||
@@ -158,16 +246,33 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"name": req.Name,
|
||||
"description": req.Description,
|
||||
"type": req.Type,
|
||||
"source": req.Source,
|
||||
"branch": req.Branch,
|
||||
"dockerfile": req.Dockerfile,
|
||||
"stages": req.Stages,
|
||||
"variables": req.Variables,
|
||||
}
|
||||
updates := map[string]interface{}{}
|
||||
if req.Name != "" { updates["name"] = req.Name }
|
||||
if req.JobName != "" { updates["job_name"] = req.JobName }
|
||||
if req.Description != "" { updates["description"] = req.Description }
|
||||
if req.Type != "" { updates["type"] = req.Type }
|
||||
if req.Source != "" { updates["source"] = req.Source }
|
||||
if req.Branch != "" { updates["branch"] = req.Branch }
|
||||
if req.Dockerfile != "" { updates["dockerfile"] = req.Dockerfile }
|
||||
if req.Stages != "" { updates["stages"] = req.Stages }
|
||||
if req.Variables != "" { updates["variables"] = req.Variables }
|
||||
if req.DeployMode != "" { updates["deploy_mode"] = req.DeployMode }
|
||||
if req.DeployTarget != "" { updates["deploy_target"] = req.DeployTarget }
|
||||
if req.ServerIDs != "" { updates["server_ids"] = req.ServerIDs }
|
||||
if req.K8sNamespace != "" { updates["k8s_namespace"] = req.K8sNamespace }
|
||||
if req.K8sClusterID != nil { updates["k8s_cluster_id"] = req.K8sClusterID }
|
||||
if req.RepoID != nil { updates["repo_id"] = req.RepoID }
|
||||
if req.RegistryID != nil { updates["registry_id"] = req.RegistryID }
|
||||
if req.GitCredentialID != nil { updates["git_credential_id"] = req.GitCredentialID }
|
||||
if req.RegistryCredentialID != nil { updates["registry_credential_id"] = req.RegistryCredentialID }
|
||||
if req.EnvironmentID != nil { updates["environment_id"] = req.EnvironmentID }
|
||||
if req.TriggerMode != "" { updates["trigger_mode"] = req.TriggerMode }
|
||||
if req.WatchBranches != "" { updates["watch_branches"] = req.WatchBranches }
|
||||
if req.ExcludeBranch != "" { updates["exclude_branch"] = req.ExcludeBranch }
|
||||
if req.CronExpr != "" { updates["cron_expr"] = req.CronExpr }
|
||||
if req.WebhookToken != "" { updates["webhook_token"] = req.WebhookToken }
|
||||
if req.CachePaths != "" { updates["cache_paths"] = req.CachePaths }
|
||||
if req.Enabled != nil { updates["enabled"] = req.Enabled }
|
||||
|
||||
if err := h.db.Model(&pipeline).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{
|
||||
@@ -196,7 +301,7 @@ func (h *PipelineHandler) UpdatePipeline(c *gin.Context) {
|
||||
// @Success 200 {object} models.Response
|
||||
// @Router /pipelines/{id} [delete]
|
||||
func (h *PipelineHandler) DeletePipeline(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
id := getPipelineID(c)
|
||||
|
||||
if err := h.db.Delete(&models.Pipeline{}, id).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{
|
||||
@@ -222,50 +327,59 @@ func (h *PipelineHandler) DeletePipeline(c *gin.Context) {
|
||||
// @Param id path int true "流水线ID"
|
||||
// @Param request body models.TriggerPipelineRequest true "触发请求"
|
||||
// @Success 200 {object} models.Response{data=models.PipelineRun}
|
||||
// @Router /pipelines/{id}/trigger [post]
|
||||
// TriggerPipeline 触发流水线
|
||||
func (h *PipelineHandler) TriggerPipeline(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
id := getPipelineID(c)
|
||||
if id == 0 {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的流水线编码"})
|
||||
return
|
||||
}
|
||||
|
||||
var pipeline models.Pipeline
|
||||
if err := h.db.First(&pipeline, id).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{
|
||||
Code: 404,
|
||||
Message: "流水线不存在",
|
||||
})
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
var req models.TriggerPipelineRequest
|
||||
c.ShouldBindJSON(&req)
|
||||
// 获取最新构建号
|
||||
var maxBuildNumber int
|
||||
h.db.Model(&models.PipelineRun{}).Where("pipeline_id = ?", id).Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber)
|
||||
|
||||
var maxBuildNumber int
|
||||
h.db.Model(&models.PipelineRun{}).Where("pipeline_id = ?", id).
|
||||
Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber)
|
||||
|
||||
branch := req.Branch
|
||||
tag := req.Tag
|
||||
ref := branch
|
||||
if req.Tag != "" {
|
||||
ref = tag
|
||||
} else if branch == "" {
|
||||
ref = pipeline.Branch
|
||||
}
|
||||
|
||||
username, _ := c.Get("username")
|
||||
userStr, _ := username.(string)
|
||||
|
||||
now := time.Now()
|
||||
run := models.PipelineRun{
|
||||
PipelineID: uint(id),
|
||||
BuildNumber: maxBuildNumber + 1,
|
||||
Status: "pending",
|
||||
Branch: req.Branch,
|
||||
TriggeredBy: "manual",
|
||||
}
|
||||
|
||||
if req.Branch == "" {
|
||||
run.Branch = pipeline.Branch
|
||||
Status: "running",
|
||||
Branch: ref,
|
||||
ImageTag: tag, // 发布 Tag
|
||||
TriggeredBy: userStr,
|
||||
StartedAt: &now,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&run).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{
|
||||
Code: 500,
|
||||
Message: "触发失败",
|
||||
})
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "触发失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Message: "触发成功",
|
||||
Data: run,
|
||||
})
|
||||
// 后台异步执行流水线
|
||||
go h.executePipeline(pipeline, run)
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "已触发", Data: run})
|
||||
}
|
||||
|
||||
// ListRuns 获取运行记录列表
|
||||
@@ -280,7 +394,7 @@ func (h *PipelineHandler) TriggerPipeline(c *gin.Context) {
|
||||
// @Success 200 {object} models.Response{data=models.ListResponse}
|
||||
// @Router /pipelines/{id}/runs [get]
|
||||
func (h *PipelineHandler) ListRuns(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
id := getPipelineID(c)
|
||||
var query models.PageQuery
|
||||
c.ShouldBindQuery(&query)
|
||||
|
||||
@@ -290,7 +404,7 @@ func (h *PipelineHandler) ListRuns(c *gin.Context) {
|
||||
db.Count(&total)
|
||||
|
||||
var runs []models.PipelineRun
|
||||
db.Order("created_at DESC").
|
||||
db.Order("started_at DESC").
|
||||
Offset(query.GetOffset()).
|
||||
Limit(query.GetLimit()).
|
||||
Find(&runs)
|
||||
@@ -304,3 +418,91 @@ func (h *PipelineHandler) ListRuns(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// WebhookTrigger 外部 Webhook 触发流水线 (支持 URL: /webhook/job/:jobName 和 /webhook/pipeline/:code)
|
||||
func (h *PipelineHandler) WebhookTrigger(c *gin.Context) {
|
||||
jobName := c.Param("jobName")
|
||||
code := c.Param("code")
|
||||
|
||||
var pipeline models.Pipeline
|
||||
if jobName != "" {
|
||||
if err := h.db.Where("job_name = ? AND enabled = ?", jobName, true).First(&pipeline).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "Job 不存在或已禁用"})
|
||||
return
|
||||
}
|
||||
} else if code != "" {
|
||||
if err := h.db.Where("code = ? AND enabled = ?", code, true).First(&pipeline).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "流水线不存在或已禁用"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "缺少 jobName 或 code"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 Webhook Token
|
||||
if pipeline.WebhookToken != "" {
|
||||
token := c.GetHeader("X-Webhook-Token")
|
||||
if token == "" {
|
||||
token = c.Query("token")
|
||||
}
|
||||
if token != pipeline.WebhookToken {
|
||||
c.JSON(http.StatusForbidden, models.Response{Code: 403, Message: "Webhook Token 无效"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 解析请求体获取 branch/tag
|
||||
var req models.TriggerPipelineRequest
|
||||
c.ShouldBindJSON(&req)
|
||||
|
||||
var maxBuildNumber int
|
||||
h.db.Model(&models.PipelineRun{}).Where("pipeline_id = ?", pipeline.ID).
|
||||
Select("COALESCE(MAX(build_number), 0)").Scan(&maxBuildNumber)
|
||||
|
||||
ref := req.Branch
|
||||
if req.Tag != "" {
|
||||
ref = req.Tag
|
||||
} else if ref == "" {
|
||||
ref = pipeline.Branch
|
||||
}
|
||||
|
||||
triggeredBy := "webhook"
|
||||
if reqPayload, exists := c.Get("raw_body"); exists {
|
||||
// 尝试从 webhook payload 中提取提交者信息
|
||||
if body, ok := reqPayload.(map[string]interface{}); ok {
|
||||
if pusher, ok := body["pusher"].(map[string]interface{}); ok {
|
||||
if name, ok := pusher["name"].(string); ok {
|
||||
triggeredBy = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
run := models.PipelineRun{
|
||||
PipelineID: pipeline.ID,
|
||||
BuildNumber: maxBuildNumber + 1,
|
||||
Status: "running",
|
||||
Branch: ref,
|
||||
ImageTag: req.Tag,
|
||||
TriggeredBy: triggeredBy,
|
||||
StartedAt: &now,
|
||||
}
|
||||
|
||||
if err := h.db.Create(&run).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "触发失败"})
|
||||
return
|
||||
}
|
||||
|
||||
go h.executePipeline(pipeline, run)
|
||||
|
||||
c.JSON(http.StatusOK, models.Response{
|
||||
Code: 200,
|
||||
Message: "已触发",
|
||||
Data: map[string]interface{}{
|
||||
"build_number": run.BuildNumber,
|
||||
"run_id": run.ID,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ==================== 流水线执行引擎 V2(Redis 实时日志推送)====================
|
||||
|
||||
// LogEntry 日志条目
|
||||
type LogEntry struct {
|
||||
Time string `json:"time"`
|
||||
Level string `json:"level"`
|
||||
Stage string `json:"stage"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// StageStep 单个阶段定义
|
||||
type StageStep struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // checkout/build/test/image/deploy/verify
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
var logger *zap.Logger
|
||||
|
||||
func init() {
|
||||
logger, _ = zap.NewProduction()
|
||||
}
|
||||
|
||||
// executePipeline 后台执行流水线(goroutine)— V2 Redis 版
|
||||
func (h *PipelineHandler) executePipeline(pipeline models.Pipeline, run models.PipelineRun) {
|
||||
runID := run.ID
|
||||
ctx := context.Background()
|
||||
logKey := fmt.Sprintf("pipeline:run:%d:logs", runID)
|
||||
pubCh := fmt.Sprintf("pipeline:run:%d", runID)
|
||||
|
||||
// 日志函数:同时写 Redis buffer + 发布 Pub/Sub
|
||||
log := func(level, stage, msg string) {
|
||||
entry := LogEntry{Time: time.Now().Format("15:04:05"), Level: level, Stage: stage, Message: msg}
|
||||
data, _ := json.Marshal(entry)
|
||||
|
||||
// 追加到 Redis 列表
|
||||
if h.cache != nil {
|
||||
existing, _ := h.cache.Get(ctx, logKey)
|
||||
var entries []LogEntry
|
||||
if existing != "" {
|
||||
json.Unmarshal([]byte(existing), &entries)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
buf, _ := json.Marshal(entries)
|
||||
h.cache.Set(ctx, logKey, string(buf), 30*time.Minute)
|
||||
// Pub/Sub 实时推送
|
||||
h.cache.Publish(ctx, pubCh, string(data))
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus := func(status string, duration int) {
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{"status": status}
|
||||
if duration > 0 {
|
||||
updates["duration"] = duration
|
||||
}
|
||||
if status == "success" || status == "failed" || status == "cancelled" {
|
||||
updates["finished_at"] = &now
|
||||
}
|
||||
h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Updates(updates)
|
||||
// 推送状态变更
|
||||
if h.cache != nil {
|
||||
statusData, _ := json.Marshal(map[string]interface{}{"type": "status", "status": status, "duration": duration})
|
||||
h.cache.Publish(ctx, pubCh, string(statusData))
|
||||
}
|
||||
}
|
||||
|
||||
updatePipelineStats := func(status string) {
|
||||
ups := map[string]interface{}{"last_status": status, "last_run_at": time.Now(), "run_count": pipeline.RunCount + 1}
|
||||
if status == "success" {
|
||||
ups["success_count"] = pipeline.SuccessCount + 1
|
||||
}
|
||||
if status == "failed" {
|
||||
ups["fail_count"] = pipeline.FailCount + 1
|
||||
}
|
||||
h.db.Model(&models.Pipeline{}).Where("id = ?", pipeline.ID).Updates(ups)
|
||||
}
|
||||
|
||||
isCancelled := func() bool {
|
||||
var current models.PipelineRun
|
||||
h.db.First(¤t, runID)
|
||||
return current.Status == "cancelled"
|
||||
}
|
||||
|
||||
// 解析阶段
|
||||
var stages []StageStep
|
||||
if pipeline.Stages != "" {
|
||||
json.Unmarshal([]byte(pipeline.Stages), &stages)
|
||||
}
|
||||
if len(stages) == 0 {
|
||||
log("warn", "init", "没有配置阶段,流水线结束")
|
||||
log("success", "完成", "流水线执行成功 (0s)")
|
||||
updateStatus("success", 0)
|
||||
updatePipelineStats("success")
|
||||
h.flushLogsToDB(ctx, runID, logKey)
|
||||
return
|
||||
}
|
||||
|
||||
// 工作目录 — 按仓库名+分支+时间戳命名,方便区分不同构建
|
||||
branch := run.Branch
|
||||
if branch == "" {
|
||||
branch = pipeline.Branch
|
||||
}
|
||||
gitURL := pipeline.Source
|
||||
// 从 Git URL 提取仓库名 (e.g. https://gitee.com/attacker/demo-jar.git → demo-jar)
|
||||
repoName := extractRepoName(gitURL)
|
||||
if repoName == "" {
|
||||
repoName = pipeline.Code
|
||||
}
|
||||
ts := time.Now()
|
||||
tsDir := fmt.Sprintf("%d%02d%02d%02d%02d", ts.Year(), ts.Month(), ts.Day(), ts.Hour(), ts.Minute())
|
||||
workspace := fmt.Sprintf("/app/workspace/%s-%s", repoName, pipeline.Code)
|
||||
|
||||
os.RemoveAll(workspace)
|
||||
os.MkdirAll(workspace, 0755)
|
||||
// 保存 workspace 路径到运行记录,方便终端查看
|
||||
h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Update("workspace_path", workspace)
|
||||
|
||||
imageTag := fmt.Sprintf("%s-%s", repoName, tsDir)
|
||||
|
||||
log("info", "init", fmt.Sprintf("触发方式: 手动 (admin)"))
|
||||
log("info", "init", fmt.Sprintf("分支: %s | 镜像标签: %s", branch, imageTag))
|
||||
|
||||
totalStart := time.Now()
|
||||
|
||||
for _, stage := range stages {
|
||||
if isCancelled() {
|
||||
log("warn", stage.Name, "流水线已被停止")
|
||||
updateStatus("cancelled", int(time.Since(totalStart).Seconds()))
|
||||
updatePipelineStats("cancelled")
|
||||
h.flushLogsToDB(ctx, runID, logKey)
|
||||
return
|
||||
}
|
||||
|
||||
log("info", stage.Name, fmt.Sprintf("===== 阶段: %s =====", stage.Name))
|
||||
stageStart := time.Now()
|
||||
|
||||
var err error
|
||||
switch stage.Type {
|
||||
case "checkout":
|
||||
err = h.runCheckout(gitURL, branch, workspace, log)
|
||||
case "build":
|
||||
err = h.runBuild(workspace, pipeline.Dockerfile, log)
|
||||
case "test":
|
||||
err = h.runTest(workspace, log)
|
||||
case "image":
|
||||
err = h.runDockerBuild(workspace, pipeline, imageTag, log)
|
||||
case "deploy":
|
||||
err = h.runDeploy(workspace, pipeline, imageTag, log)
|
||||
case "verify":
|
||||
err = h.runVerify(workspace, log)
|
||||
default:
|
||||
log("info", stage.Name, "跳过未知阶段类型: "+stage.Type)
|
||||
}
|
||||
|
||||
stageDur := time.Since(stageStart)
|
||||
if err != nil {
|
||||
log("error", stage.Name, fmt.Sprintf("✗ %s 失败 (%v): %v", stage.Name, stageDur.Round(time.Second), err))
|
||||
updateStatus("failed", int(time.Since(totalStart).Seconds()))
|
||||
updatePipelineStats("failed")
|
||||
h.flushLogsToDB(ctx, runID, logKey)
|
||||
return
|
||||
}
|
||||
log("success", stage.Name, fmt.Sprintf("✓ %s 完成 (%v)", stage.Name, stageDur.Round(time.Second)))
|
||||
}
|
||||
|
||||
totalDur := int(time.Since(totalStart).Seconds())
|
||||
updateStatus("success", totalDur)
|
||||
updatePipelineStats("success")
|
||||
log("success", "完成", fmt.Sprintf("流水线执行成功 (%ds)", totalDur))
|
||||
|
||||
// 执行结束 → Redis 日志批量写入 MySQL
|
||||
h.flushLogsToDB(ctx, runID, logKey)
|
||||
}
|
||||
|
||||
// flushLogsToDB Redis 日志批量写入 MySQL
|
||||
func (h *PipelineHandler) flushLogsToDB(ctx context.Context, runID uint, logKey string) {
|
||||
if h.cache == nil {
|
||||
return
|
||||
}
|
||||
raw, err := h.cache.Get(ctx, logKey)
|
||||
if err != nil || raw == "" {
|
||||
return
|
||||
}
|
||||
var entries []LogEntry
|
||||
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
|
||||
return
|
||||
}
|
||||
logsJSON, _ := json.Marshal(entries)
|
||||
h.db.Model(&models.PipelineRun{}).Where("id = ?", runID).Update("logs", string(logsJSON))
|
||||
// 清理 Redis 缓存
|
||||
h.cache.Del(ctx, logKey)
|
||||
}
|
||||
|
||||
// runCheckout git clone
|
||||
func (h *PipelineHandler) runCheckout(url, branch, workspace string, logFn func(string, string, string)) error {
|
||||
if url == "" {
|
||||
logFn("warn", "代码检出", "没有配置 Git URL,跳过代码检出")
|
||||
return nil
|
||||
}
|
||||
logFn("info", "代码检出", "> Git 拉取 "+url+" (分支: "+branch+")")
|
||||
|
||||
cmd := exec.Command("git", "clone", "--depth", "1", "-b", branch, url, workspace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
// 流式输出到日志
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "代码检出", line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("git clone 失败: %v", err)
|
||||
}
|
||||
hashCmd := exec.Command("git", "-C", workspace, "rev-parse", "--short", "HEAD")
|
||||
hashOut, _ := hashCmd.Output()
|
||||
logFn("success", "代码检出", fmt.Sprintf("已拉取 commit: %s", strings.TrimSpace(string(hashOut))))
|
||||
return nil
|
||||
}
|
||||
|
||||
// runBuild 构建(自动检测 Maven / npm / Go / Makefile)
|
||||
func (h *PipelineHandler) runBuild(workspace, dockerfile string, logFn func(string, string, string)) error {
|
||||
buildDir := workspace
|
||||
if dockerfile != "" {
|
||||
if dir := dotdot(dockerfile); dir != "." && dir != "" {
|
||||
testDir := workspace + "/" + dir
|
||||
if _, err := os.Stat(testDir); err == nil {
|
||||
buildDir = testDir
|
||||
logFn("info", "构建", ">>> 切换到构建目录: "+dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果根目录没找到构建文件,扫描一级子目录
|
||||
if !hasBuildFile(buildDir) {
|
||||
type subMatch struct {
|
||||
dir string
|
||||
kind string // "maven", "npm", "go", "make"
|
||||
}
|
||||
var matches []subMatch
|
||||
entries, err := os.ReadDir(buildDir)
|
||||
if err == nil {
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || e.Name() == ".git" {
|
||||
continue
|
||||
}
|
||||
sub := buildDir + "/" + e.Name()
|
||||
if _, err := os.Stat(sub + "/pom.xml"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "maven"})
|
||||
}
|
||||
if _, err := os.Stat(sub + "/package.json"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "npm"})
|
||||
}
|
||||
if _, err := os.Stat(sub + "/go.mod"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "go"})
|
||||
}
|
||||
if _, err := os.Stat(sub + "/Makefile"); err == nil {
|
||||
matches = append(matches, subMatch{sub, "make"})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(matches) > 0 {
|
||||
// 有多个匹配时列出,优先选择第一个
|
||||
if len(matches) > 1 {
|
||||
names := make([]string, len(matches))
|
||||
for i, m := range matches {
|
||||
names[i] = fmt.Sprintf("%s(%s)", m.dir[len(buildDir)+1:], m.kind)
|
||||
}
|
||||
logFn("info", "构建", ">>> 发现多个构建目录: "+strings.Join(names, ", "))
|
||||
}
|
||||
buildDir = matches[0].dir
|
||||
logFn("info", "构建", ">>> 自动检测到构建目录: "+buildDir[len(workspace)+1:])
|
||||
} else {
|
||||
logFn("info", "构建", "未检测到构建文件 (pom.xml/go.mod/package.json),跳过构建")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := os.Stat(buildDir + "/pom.xml"); err == nil {
|
||||
logFn("info", "构建", ">>> Maven 构建 (mvn clean package -DskipTests)")
|
||||
logFn("info", "构建", "🔨 正在下载依赖并编译...")
|
||||
cmd := exec.Command("mvn", "clean", "package", "-DskipTests")
|
||||
cmd.Dir = buildDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "构建", line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Maven 构建失败: %v", err)
|
||||
}
|
||||
logFn("success", "构建", "Maven 构建成功 ✅")
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(buildDir + "/package.json"); err == nil {
|
||||
logFn("info", "构建", ">>> npm 构建 (npm install && npm run build)")
|
||||
logFn("info", "构建", "📦 正在安装依赖...")
|
||||
installCmd := exec.Command("sh", "-c", "npm install --registry=https://registry.npmmirror.com --legacy-peer-deps 2>&1")
|
||||
installCmd.Dir = buildDir
|
||||
out, err := installCmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "构建", line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("npm install 失败: %v", err)
|
||||
}
|
||||
logFn("info", "构建", "🏗️ 正在打包构建...")
|
||||
buildCmd := exec.Command("sh", "-c", "npm run build 2>&1")
|
||||
buildCmd.Dir = buildDir
|
||||
out, err = buildCmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "构建", line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("npm build 失败: %v", err)
|
||||
}
|
||||
logFn("success", "构建", "npm 构建成功 ✅")
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(buildDir + "/go.mod"); err == nil {
|
||||
logFn("info", "构建", ">>> Go 构建 (go build ./...)")
|
||||
cmd := exec.Command("go", "build", "./...")
|
||||
cmd.Dir = buildDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "构建", line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Go 构建失败: %v", err)
|
||||
}
|
||||
logFn("success", "构建", "Go 构建成功")
|
||||
return nil
|
||||
}
|
||||
|
||||
logFn("info", "构建", "未检测到构建文件 (pom.xml/go.mod/package.json),跳过构建")
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasBuildFile 检查目录是否包含可识别的构建文件
|
||||
func hasBuildFile(dir string) bool {
|
||||
for _, f := range []string{"pom.xml", "package.json", "go.mod", "Makefile"} {
|
||||
if _, err := os.Stat(dir + "/" + f); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runTest 执行测试
|
||||
func (h *PipelineHandler) runTest(workspace string, logFn func(string, string, string)) error {
|
||||
if _, err := os.Stat(workspace + "/pom.xml"); err == nil {
|
||||
logFn("info", "测试", ">>> Maven 测试 (mvn test)")
|
||||
cmd := exec.Command("mvn", "test")
|
||||
cmd.Dir = workspace
|
||||
out, _ := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "测试", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDockerBuild Docker 构建 & 推送
|
||||
func (h *PipelineHandler) runDockerBuild(workspace string, pipeline models.Pipeline, tag string, logFn func(string, string, string)) error {
|
||||
dockerfile := pipeline.Dockerfile
|
||||
if dockerfile == "" {
|
||||
dockerfile = "Dockerfile"
|
||||
}
|
||||
|
||||
var registryURL, imageName string
|
||||
if pipeline.RegistryID != nil {
|
||||
var reg models.PipelineRegistry
|
||||
if err := h.db.First(®, *pipeline.RegistryID).Error; err == nil {
|
||||
registryURL = reg.RegistryURL
|
||||
if reg.Namespace != "" {
|
||||
imageName = reg.Namespace + "/" + pipeline.Code
|
||||
}
|
||||
}
|
||||
}
|
||||
if imageName == "" {
|
||||
imageName = pipeline.Code
|
||||
}
|
||||
if registryURL == "" {
|
||||
registryURL = "registry.example.com"
|
||||
}
|
||||
|
||||
fullImage := fmt.Sprintf("%s/%s:%s", registryURL, imageName, tag)
|
||||
logFn("info", "镜像打包", fmt.Sprintf("镜像地址: %s", fullImage))
|
||||
|
||||
buildDir := workspace
|
||||
if dir := dotdot(dockerfile); dir != "." && dir != "" {
|
||||
buildDir = workspace + "/" + dir
|
||||
}
|
||||
dfPath := dockerfile
|
||||
if strings.Contains(dockerfile, "/") {
|
||||
dfPath = dockerfile[strings.LastIndex(dockerfile, "/")+1:]
|
||||
}
|
||||
|
||||
logFn("info", "镜像打包", fmt.Sprintf(">>> Docker 构建 (dockerfile: %s)", dfPath))
|
||||
cmd := exec.Command("docker", "build", "-t", fullImage, "-f", dfPath, ".")
|
||||
cmd.Dir = buildDir
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "镜像打包", line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Docker 构建失败: %v", err)
|
||||
}
|
||||
logFn("success", "镜像打包", "Docker 构建成功")
|
||||
|
||||
logFn("info", "镜像打包", ">>> 推送镜像: "+fullImage)
|
||||
pushCmd := exec.Command("docker", "push", fullImage)
|
||||
pushOut, err := pushCmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(pushOut)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "镜像打包", line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("Docker 推送失败: %v", err)
|
||||
}
|
||||
logFn("success", "镜像打包", "镜像推送完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDeploy 部署到 K8s / 服务器
|
||||
func (h *PipelineHandler) runDeploy(workspace string, pipeline models.Pipeline, tag string, logFn func(string, string, string)) error {
|
||||
deployYamls := findFiles(workspace, ".yaml", ".yml")
|
||||
var k8sFiles []string
|
||||
for _, f := range deployYamls {
|
||||
content, _ := os.ReadFile(f)
|
||||
if strings.Contains(string(content), "kind:") &&
|
||||
(strings.Contains(string(content), "Deployment") ||
|
||||
strings.Contains(string(content), "Service") ||
|
||||
strings.Contains(string(content), "StatefulSet")) {
|
||||
k8sFiles = append(k8sFiles, f)
|
||||
}
|
||||
}
|
||||
|
||||
if len(k8sFiles) == 0 {
|
||||
logFn("info", "部署", "未找到 K8s 部署文件,跳过部署")
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, yamlFile := range k8sFiles {
|
||||
logFn("info", "部署", ">>> kubectl apply -f "+yamlFile)
|
||||
cmd := exec.Command("kubectl", "apply", "-f", yamlFile)
|
||||
out, err := cmd.CombinedOutput()
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "部署", line)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
logFn("error", "部署", fmt.Sprintf("kubectl apply 失败: %v", err))
|
||||
return fmt.Errorf("kubectl apply 失败")
|
||||
}
|
||||
}
|
||||
logFn("success", "部署", "部署完成")
|
||||
return nil
|
||||
}
|
||||
|
||||
// runVerify 部署后验证
|
||||
func (h *PipelineHandler) runVerify(workspace string, logFn func(string, string, string)) error {
|
||||
logFn("info", "验证", ">>> 部署后健康检查")
|
||||
cmd := exec.Command("kubectl", "get", "pods", "-o", "wide")
|
||||
cmd.Dir = workspace
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
logFn("warn", "验证", fmt.Sprintf("kubectl get pods 失败: %v", err))
|
||||
}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line != "" {
|
||||
logFn("info", "验证", line)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ======== helpers ========
|
||||
|
||||
func dotdot(path string) string {
|
||||
i := strings.LastIndex(path, "/")
|
||||
if i < 0 {
|
||||
return "."
|
||||
}
|
||||
return path[:i]
|
||||
}
|
||||
|
||||
// extractRepoName 从 Git URL 提取仓库名
|
||||
// e.g. https://gitee.com/attacker/demo-jar.git → demo-jar
|
||||
func extractRepoName(url string) string {
|
||||
// Remove trailing .git
|
||||
clean := strings.TrimSuffix(url, ".git")
|
||||
// Get last path segment
|
||||
idx := strings.LastIndex(clean, "/")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return clean[idx+1:]
|
||||
}
|
||||
|
||||
// cleanOldWorkspaces 清理超过 maxAge 的 workspace 目录
|
||||
func cleanOldWorkspaces(baseDir string, maxAge time.Duration) {
|
||||
entries, err := os.ReadDir(baseDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(info.ModTime()) > maxAge {
|
||||
os.RemoveAll(baseDir + "/" + e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func findFiles(dir string, exts ...string) []string {
|
||||
var result []string
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, e := range entries {
|
||||
full := dir + "/" + e.Name()
|
||||
if e.IsDir() {
|
||||
if e.Name() != ".git" {
|
||||
result = append(result, findFiles(full, exts...)...)
|
||||
}
|
||||
} else {
|
||||
for _, ext := range exts {
|
||||
if strings.HasSuffix(e.Name(), ext) {
|
||||
result = append(result, full)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func (h *ServerHandler) BatchCreateServers(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "凭据解密失败"})
|
||||
return
|
||||
}
|
||||
if cred.Type == "ssh_key" {
|
||||
if cred.SecretType == "ssh" {
|
||||
decryptedKey = plain
|
||||
authType = "key"
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ezops/internal/models"
|
||||
)
|
||||
|
||||
// WorkspaceFilesHandler 工作区文件浏览器处理器
|
||||
type WorkspaceFilesHandler struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewWorkspaceFilesHandler(db *gorm.DB) *WorkspaceFilesHandler {
|
||||
return &WorkspaceFilesHandler{db: db}
|
||||
}
|
||||
|
||||
// FileInfo 文件信息
|
||||
type FileInfo struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
Size int64 `json:"size"`
|
||||
ModTime string `json:"mod_time"`
|
||||
}
|
||||
|
||||
// resolveWorkspace 解析 run ID 获取 workspace 路径
|
||||
func (h *WorkspaceFilesHandler) resolveWorkspace(c *gin.Context) (string, error) {
|
||||
runID, err := strconv.Atoi(c.Param("runId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid run id"})
|
||||
return "", err
|
||||
}
|
||||
|
||||
var run models.PipelineRun
|
||||
if err := h.db.First(&run, runID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
|
||||
return "", err
|
||||
}
|
||||
|
||||
if run.WorkspacePath == "" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "workspace path not available, pipeline may still be running"})
|
||||
return "", fmt.Errorf("no workspace")
|
||||
}
|
||||
|
||||
return run.WorkspacePath, nil
|
||||
}
|
||||
|
||||
// safePath 安全拼接路径,防止目录穿越
|
||||
func safePath(base, sub string) (string, error) {
|
||||
if sub == "" || sub == "." {
|
||||
sub = "/"
|
||||
}
|
||||
// 清理路径
|
||||
clean := filepath.Clean(sub)
|
||||
if filepath.IsAbs(clean) {
|
||||
// 去掉根前缀,转为相对路径
|
||||
clean = clean[1:]
|
||||
}
|
||||
fullPath := filepath.Join(base, clean)
|
||||
// 确保不逃逸出 base
|
||||
absBase, _ := filepath.Abs(base)
|
||||
absFull, err := filepath.Abs(fullPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
if !strings.HasPrefix(absFull, absBase) {
|
||||
return "", fmt.Errorf("path traversal detected")
|
||||
}
|
||||
return fullPath, nil
|
||||
}
|
||||
|
||||
// ListFiles 列出目录内容
|
||||
// GET /api/v1/pipelines/runs/:runId/files?path=subdir
|
||||
func (h *WorkspaceFilesHandler) ListFiles(c *gin.Context) {
|
||||
workspace, err := h.resolveWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
subPath := c.Query("path")
|
||||
targetPath, err := safePath(workspace, subPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(targetPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "directory not found"})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read directory"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
files := make([]FileInfo, 0)
|
||||
dirs := make([]FileInfo, 0)
|
||||
|
||||
for _, entry := range entries {
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
rel, _ := filepath.Rel(workspace, filepath.Join(targetPath, entry.Name()))
|
||||
fi := FileInfo{
|
||||
Name: entry.Name(),
|
||||
Path: rel,
|
||||
IsDir: entry.IsDir(),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
if entry.IsDir() {
|
||||
dirs = append(dirs, fi)
|
||||
} else {
|
||||
files = append(files, fi)
|
||||
}
|
||||
}
|
||||
|
||||
// 当前路径(相对于 workspace)
|
||||
currentRel, _ := filepath.Rel(workspace, targetPath)
|
||||
if currentRel == "." {
|
||||
currentRel = ""
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"current": currentRel,
|
||||
"dirs": dirs,
|
||||
"files": files,
|
||||
})
|
||||
}
|
||||
|
||||
// DownloadFile 下载文件
|
||||
// GET /api/v1/pipelines/runs/:runId/files/download?path=file
|
||||
func (h *WorkspaceFilesHandler) DownloadFile(c *gin.Context) {
|
||||
workspace, err := h.resolveWorkspace(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
subPath := c.Query("path")
|
||||
if subPath == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "path is required"})
|
||||
return
|
||||
}
|
||||
|
||||
targetPath, err := safePath(workspace, subPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
if info.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "cannot download a directory"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := os.Open(targetPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open file"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filepath.Base(targetPath)))
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Length", fmt.Sprintf("%d", info.Size()))
|
||||
io.Copy(c.Writer, file)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ezredis "ezops/pkg/redis"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var wsUpgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// WSPipelineLogHandler WebSocket 流水线日志推流
|
||||
type WSPipelineLogHandler struct {
|
||||
db *gorm.DB
|
||||
cache ezredis.Cache
|
||||
}
|
||||
|
||||
// NewWSPipelineLogHandler 创建 WebSocket 日志处理器
|
||||
func NewWSPipelineLogHandler(db *gorm.DB, cache ezredis.Cache) *WSPipelineLogHandler {
|
||||
return &WSPipelineLogHandler{db: db, cache: cache}
|
||||
}
|
||||
|
||||
// StreamLog WebSocket 端点:/ws/pipeline/:runId/log
|
||||
func (h *WSPipelineLogHandler) StreamLog(c *gin.Context) {
|
||||
runIDStr := c.Param("runId")
|
||||
runID, err := strconv.ParseUint(runIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 runId"})
|
||||
return
|
||||
}
|
||||
|
||||
ws, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
|
||||
logKey := fmt.Sprintf("pipeline:run:%d:logs", uint(runID))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// 首次发送:已有日志
|
||||
raw, _ := h.cache.Get(ctx, logKey)
|
||||
if raw != "" {
|
||||
ws.WriteJSON(map[string]interface{}{"type": "init", "logs": raw})
|
||||
}
|
||||
|
||||
// 轮询新日志(500ms 间隔)
|
||||
lastLen := 0
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
_, _, err := ws.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
raw, err := h.cache.Get(ctx, logKey)
|
||||
if err != nil || raw == "" {
|
||||
continue
|
||||
}
|
||||
var entries []LogEntry
|
||||
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(entries) > lastLen {
|
||||
// 增量推送
|
||||
newEntries := entries[lastLen:]
|
||||
lastLen = len(entries)
|
||||
for _, e := range newEntries {
|
||||
buf, _ := json.Marshal(e)
|
||||
if err := ws.WriteMessage(websocket.TextMessage, buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查流水线是否已结束(日志 key 被删除 = 已写入 DB)
|
||||
if lastLen > 0 {
|
||||
exists, _ := h.cache.Exists(ctx, logKey)
|
||||
if exists == 0 {
|
||||
// 发送完成信号
|
||||
ws.WriteJSON(map[string]interface{}{"type": "done"})
|
||||
return
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetLogHistory HTTP 日志历史(已完成的任务从 DB 读取)
|
||||
func (h *WSPipelineLogHandler) GetLogHistory(c *gin.Context) {
|
||||
runIDStr := c.Param("runId")
|
||||
runID, err := strconv.ParseUint(runIDStr, 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 runId"})
|
||||
return
|
||||
}
|
||||
|
||||
// 先查 Redis(执行中)
|
||||
logKey := fmt.Sprintf("pipeline:run:%d:logs", uint(runID))
|
||||
ctx := c.Request.Context()
|
||||
|
||||
raw, _ := h.cache.Get(ctx, logKey)
|
||||
if raw != "" && !strings.HasPrefix(raw, "[") {
|
||||
raw = "[]"
|
||||
}
|
||||
if raw != "" {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"source": "redis", "logs": raw}})
|
||||
return
|
||||
}
|
||||
|
||||
// 查 DB(已完成)
|
||||
var logs string
|
||||
h.db.Table("ops_pipeline_runs").Where("id = ?", uint(runID)).Select("logs").Scan(&logs)
|
||||
if logs == "" {
|
||||
logs = "[]"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 200, "data": gin.H{"source": "db", "logs": logs}})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"ezops/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PipelineAccess 流水线权限中间件
|
||||
// 路由参数 :code(流水线编码)→ 查找 pipeline → 注入 pipelineID 到 context
|
||||
// 权限检查顺序:管理员 → 直接成员 → 用户组 → Item Role (regex)
|
||||
func PipelineAccess(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 1) 解析 code → 查找流水线(所有用户都需要)
|
||||
code := c.Param("code")
|
||||
if code == "" {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"code": 400, "message": "缺少流水线编码"})
|
||||
return
|
||||
}
|
||||
|
||||
var pipeline models.Pipeline
|
||||
if err := db.Where("code = ?", code).First(&pipeline).Error; err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"code": 404, "message": "流水线不存在"})
|
||||
return
|
||||
}
|
||||
|
||||
// 注入 context,后续 handlers 统一从这里读
|
||||
c.Set("pipelineID", pipeline.ID)
|
||||
c.Set("pipelineCode", code)
|
||||
|
||||
// 2) 管理员直接放行(跳过权限检查)
|
||||
if isAdmin, _ := c.Get("isAdmin"); isAdmin == true {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
username, _ := c.Get("username")
|
||||
usernameStr, _ := username.(string)
|
||||
if usernameStr == "" {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "未认证"})
|
||||
return
|
||||
}
|
||||
|
||||
pipelineID := pipeline.ID
|
||||
|
||||
// 3) 检查是否直接是流水线成员
|
||||
var directCount int64
|
||||
db.Model(&models.DeployPipelineMember{}).
|
||||
Where("pipeline_id = ? AND username = ?", pipelineID, usernameStr).
|
||||
Count(&directCount)
|
||||
if directCount > 0 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 4) 检查是否属于授权给该流水线的用户组
|
||||
var groupCount int64
|
||||
db.Table("deploy_pipeline_members").
|
||||
Joins("JOIN deploy_user_group_members ON deploy_user_group_members.group_id = deploy_pipeline_members.group_id").
|
||||
Where("deploy_pipeline_members.pipeline_id = ? AND deploy_user_group_members.username = ?", pipelineID, usernameStr).
|
||||
Count(&groupCount)
|
||||
if groupCount > 0 {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 5) Jenkins-style Item Role — regex pattern matching on pipeline code
|
||||
var itemRoles []models.ItemRole
|
||||
db.Table("iam_item_roles").
|
||||
Joins("JOIN iam_user_item_roles ON iam_user_item_roles.item_role_id = iam_item_roles.id").
|
||||
Where("iam_item_roles.status = 1 AND iam_user_item_roles.user_id = ?",
|
||||
func() uint { uid, _ := c.Get("userID"); return uid.(uint) }()).
|
||||
Find(&itemRoles)
|
||||
for _, ir := range itemRoles {
|
||||
if matched, _ := regexp.MatchString(ir.Pattern, code); matched {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"code": 403,
|
||||
"message": "无权访问此流水线,请联系流水线 Owner 添加你为成员",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// GetAccessiblePipelineIDs 获取当前用户有权访问的流水线 ID 列表
|
||||
// 包括:直接成员 + 用户组 + Item Role (regex)
|
||||
func GetAccessiblePipelineIDs(db *gorm.DB, username string, userID uint) ([]uint, bool) {
|
||||
var directIDs []uint
|
||||
db.Model(&models.DeployPipelineMember{}).
|
||||
Where("username = ?", username).
|
||||
Pluck("pipeline_id", &directIDs)
|
||||
|
||||
var groupIDs []uint
|
||||
db.Model(&models.DeployPipelineMember{}).
|
||||
Joins("JOIN deploy_user_group_members ON deploy_user_group_members.group_id = deploy_pipeline_members.group_id").
|
||||
Where("deploy_user_group_members.username = ?", username).
|
||||
Pluck("deploy_pipeline_members.pipeline_id", &groupIDs)
|
||||
|
||||
idSet := make(map[uint]bool)
|
||||
for _, id := range directIDs {
|
||||
idSet[id] = true
|
||||
}
|
||||
for _, id := range groupIDs {
|
||||
idSet[id] = true
|
||||
}
|
||||
|
||||
// Item Role regex matching: get all item roles for user, match against pipeline codes
|
||||
var itemRoles []models.ItemRole
|
||||
db.Table("iam_item_roles").
|
||||
Joins("JOIN iam_user_item_roles ON iam_user_item_roles.item_role_id = iam_item_roles.id").
|
||||
Where("iam_item_roles.status = 1 AND iam_user_item_roles.user_id = ?", userID).
|
||||
Find(&itemRoles)
|
||||
if len(itemRoles) > 0 {
|
||||
var allPipelines []models.Pipeline
|
||||
db.Find(&allPipelines)
|
||||
for _, p := range allPipelines {
|
||||
for _, ir := range itemRoles {
|
||||
if matched, _ := regexp.MatchString(ir.Pattern, p.Code); matched {
|
||||
idSet[p.ID] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ids := make([]uint, 0, len(idSet))
|
||||
for id := range idSet {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids, len(ids) > 0
|
||||
}
|
||||
@@ -16,6 +16,9 @@ import (
|
||||
// Register 注册所有路由 // Logan.Li
|
||||
// cache 参数为可选 Redis 缓存实例(支持 redka/redis 双后端)
|
||||
func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, cache interface{}) {
|
||||
// 提取 Redis 缓存实例(供流水线 WebSocket 等使用)
|
||||
redisCache, _ := cache.(ezredis.Cache)
|
||||
|
||||
// API版本前缀
|
||||
api := r.Group("/api/v1")
|
||||
|
||||
@@ -64,6 +67,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
metricsWSHandler := handlers.NewMetricsWSHandler(metricsHub)
|
||||
r.GET("/ws/metrics", middleware.JWTAuth(&cfg.JWT), metricsWSHandler.ServeWS)
|
||||
|
||||
// WebSocket 流水线日志推流
|
||||
wsLogHandler := handlers.NewWSPipelineLogHandler(db, redisCache)
|
||||
r.GET("/ws/pipeline/:runId/log", wsLogHandler.StreamLog)
|
||||
r.GET("/api/v1/pipelines/runs/:runId/log-stream", wsLogHandler.GetLogHistory)
|
||||
|
||||
// 工作区文件浏览器(浏览/下载流水线构建目录文件)
|
||||
workspaceFilesHandler := handlers.NewWorkspaceFilesHandler(db)
|
||||
r.GET("/api/v1/pipelines/runs/:runId/files", workspaceFilesHandler.ListFiles)
|
||||
r.GET("/api/v1/pipelines/runs/:runId/files/download", workspaceFilesHandler.DownloadFile)
|
||||
|
||||
// WebSocket K8s exec 交互终端 // Logan.Li
|
||||
k8sHandler := handlers.NewKubernetesHandler(db)
|
||||
r.GET("/ws/kubernetes-exec/:id/namespaces/:namespace/pods/:podName", middleware.JWTAuth(&cfg.JWT), k8sHandler.ExecTerminal)
|
||||
@@ -77,6 +90,14 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
api.POST("/auth/login", authHandler.Login)
|
||||
api.POST("/auth/register", authHandler.Register)
|
||||
|
||||
// Webhook 触发路由(无需 JWT,由 Webhook Token 签名验证)
|
||||
pipelineHandler := handlers.NewPipelineHandler(db, redisCache)
|
||||
webhookGroup := api.Group("/webhook")
|
||||
{
|
||||
webhookGroup.POST("/job/:jobName", pipelineHandler.WebhookTrigger)
|
||||
webhookGroup.POST("/pipeline/:code", pipelineHandler.WebhookTrigger)
|
||||
}
|
||||
|
||||
// Agent 安装处理器(在 servers 和 agent 路由中共享)
|
||||
agentInstallHandler := handlers.NewAgentInstallHandler(db)
|
||||
|
||||
@@ -113,6 +134,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
|
||||
// 权限列表(供角色权限选择)
|
||||
admin.GET("/permissions", authHandler.ListPermissions)
|
||||
|
||||
// Item Role 管理(Jenkins-style regex pipeline matching) // Logan.Li
|
||||
itemRoleHandler := handlers.NewItemRoleHandler(db)
|
||||
admin.GET("/item-roles", itemRoleHandler.ListItemRoles)
|
||||
admin.POST("/item-roles", itemRoleHandler.CreateItemRole)
|
||||
admin.PUT("/item-roles/:id", itemRoleHandler.UpdateItemRole)
|
||||
admin.DELETE("/item-roles/:id", itemRoleHandler.DeleteItemRole)
|
||||
admin.GET("/users/:id/item-roles", itemRoleHandler.ListUserItemRoles)
|
||||
admin.POST("/users/:id/item-roles", itemRoleHandler.AssignItemRole)
|
||||
admin.DELETE("/users/:id/item-roles/:roleId", itemRoleHandler.RemoveItemRole)
|
||||
}
|
||||
|
||||
// LDAP 配置路由
|
||||
@@ -371,16 +402,51 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
}
|
||||
|
||||
// 流水线路由
|
||||
pipelineHandler := handlers.NewPipelineHandler(db)
|
||||
pipelines := authorized.Group("/pipelines")
|
||||
{
|
||||
pipelines.GET("", pipelineHandler.ListPipelines)
|
||||
pipelines.POST("", pipelineHandler.CreatePipeline)
|
||||
pipelines.GET("/:id", pipelineHandler.GetPipeline)
|
||||
pipelines.PUT("/:id", pipelineHandler.UpdatePipeline)
|
||||
pipelines.DELETE("/:id", pipelineHandler.DeletePipeline)
|
||||
pipelines.POST("/:id/trigger", pipelineHandler.TriggerPipeline)
|
||||
pipelines.GET("/:id/runs", pipelineHandler.ListRuns)
|
||||
|
||||
// 需要流水线权限的路由
|
||||
pipelineScoped := pipelines.Group("/:code")
|
||||
pipelineScoped.Use(middleware.PipelineAccess(db))
|
||||
{
|
||||
pipelineScoped.GET("", pipelineHandler.GetPipeline)
|
||||
pipelineScoped.PUT("", pipelineHandler.UpdatePipeline)
|
||||
pipelineScoped.DELETE("", pipelineHandler.DeletePipeline)
|
||||
pipelineScoped.POST("/trigger", pipelineHandler.TriggerPipeline)
|
||||
pipelineScoped.GET("/runs", pipelineHandler.ListRuns)
|
||||
pipelineScoped.POST("/runs/:runId/stop", pipelineHandler.StopPipelineRun)
|
||||
pipelineScoped.POST("/runs/batch-delete", pipelineHandler.BatchDeleteRuns)
|
||||
pipelineScoped.GET("/runs/:runId/log", pipelineHandler.GetRunLog)
|
||||
// 成员管理
|
||||
pipelineScoped.GET("/members", pipelineHandler.ListPipelineMembers)
|
||||
pipelineScoped.POST("/members", pipelineHandler.AddPipelineMember)
|
||||
pipelineScoped.DELETE("/members/:memberId", pipelineHandler.RemovePipelineMember)
|
||||
// 组授权
|
||||
pipelineScoped.POST("/groups", pipelineHandler.AssignGroupToPipeline)
|
||||
}
|
||||
}
|
||||
|
||||
// 用户组管理路由
|
||||
groups := authorized.Group("/deploy/groups")
|
||||
{
|
||||
groups.GET("", pipelineHandler.ListGroups)
|
||||
groups.POST("", pipelineHandler.CreateGroup)
|
||||
groups.PUT("/:id", pipelineHandler.UpdateGroup)
|
||||
groups.DELETE("/:id", pipelineHandler.DeleteGroup)
|
||||
groups.GET("/:id/members", pipelineHandler.ListGroupMembers)
|
||||
groups.POST("/:id/members", pipelineHandler.AddGroupMember)
|
||||
groups.DELETE("/:id/members/:memberId", pipelineHandler.RemoveGroupMember)
|
||||
}
|
||||
|
||||
// 环境管理路由
|
||||
environments := authorized.Group("/deploy/environments")
|
||||
{
|
||||
environments.GET("", pipelineHandler.ListEnvironments)
|
||||
environments.POST("", pipelineHandler.CreateEnvironment)
|
||||
environments.PUT("/:id", pipelineHandler.UpdateEnvironment)
|
||||
environments.DELETE("/:id", pipelineHandler.DeleteEnvironment)
|
||||
}
|
||||
|
||||
// 应用发布路由
|
||||
@@ -394,6 +460,24 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
releases.POST("/:id/rollback", pipelineHandler.RollbackRelease)
|
||||
}
|
||||
|
||||
// 仓库认证 & 镜像仓库认证路由
|
||||
deployHandler := handlers.NewDeployHandler(db)
|
||||
{
|
||||
repos := authorized.Group("/deploy/repos")
|
||||
repos.GET("", deployHandler.ListRepos)
|
||||
repos.POST("", deployHandler.CreateRepo)
|
||||
repos.GET("/:id", deployHandler.GetRepo)
|
||||
repos.PUT("/:id", deployHandler.UpdateRepo)
|
||||
repos.DELETE("/:id", deployHandler.DeleteRepo)
|
||||
|
||||
registries := authorized.Group("/deploy/registries")
|
||||
registries.GET("", deployHandler.ListRegistries)
|
||||
registries.POST("", deployHandler.CreateRegistry)
|
||||
registries.GET("/:id", deployHandler.GetRegistry)
|
||||
registries.PUT("/:id", deployHandler.UpdateRegistry)
|
||||
registries.DELETE("/:id", deployHandler.DeleteRegistry)
|
||||
}
|
||||
|
||||
// 自动化运维路由
|
||||
automationHandler := handlers.NewAutomationHandler(db)
|
||||
cronJobs := authorized.Group("/cron-jobs")
|
||||
@@ -515,7 +599,6 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
}
|
||||
|
||||
// Agent 上报路由(无需 JWT)
|
||||
redisCache, _ := cache.(ezredis.Cache)
|
||||
agentHandler := handlers.NewAgentHandler(db, redisCache, metricsHub)
|
||||
taskHandler := handlers.NewAgentTaskHandler(db)
|
||||
agentGroup := api.Group("/agent")
|
||||
@@ -645,8 +728,11 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
|
||||
credGroup := authorized.Group("/credentials")
|
||||
{
|
||||
credGroup.GET("", credentialHandler.ListCredentials)
|
||||
credGroup.GET("/tags", credentialHandler.GetTags)
|
||||
credGroup.POST("", credentialHandler.CreateCredential)
|
||||
credGroup.GET("/:id", credentialHandler.GetCredential)
|
||||
credGroup.PUT("/:id", credentialHandler.UpdateCredential)
|
||||
credGroup.PATCH("/:id", credentialHandler.PatchCredential)
|
||||
credGroup.DELETE("/:id", credentialHandler.DeleteCredential)
|
||||
}
|
||||
// 服务器凭据关联 // Logan.Li
|
||||
|
||||
Reference in new issue
Block a user