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:
attacker committed 2026-06-21 05:26:01 +00:00
1 parent 0fea0ccc20
commit 24f955832e
40 files changed
+7530 -1032

No files matched your search

+14 -3
View File
@@ -61,11 +61,18 @@ FROM alpine:latest
# 使用国内 Alpine 镜像源加速 // Logan.Li
RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories
# 安装运行时依赖(bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent,kubectl=集群命令行管理,ansible=自动化运维)
RUN apk --no-cache add ca-certificates tzdata bash curl openssh-client ansible sshpass && \
# 安装运行时依赖(bash=kubectl交互终端,curl=健康检查,openssh-client=SSH远程安装agent,kubectl=集群命令行管理,ansible=自动化运维,nodejs+npm=前端构建,maven=Java构建)
RUN apk --no-cache add ca-certificates tzdata bash curl openssh-client ansible sshpass git docker-cli docker-cli-buildx nodejs npm maven && \
curl -sLO "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \
chmod +x kubectl && mv kubectl /usr/local/bin/
# Maven 阿里云镜像加速(避免 DNS 间歇性 SERVFAIL)
RUN mkdir -p /root/.m2 && \
echo '<?xml version="1.0" encoding="UTF-8"?><settings><mirrors><mirror><id>aliyun</id><mirrorOf>central</mirrorOf><name>Aliyun Maven</name><url>https://maven.aliyun.com/repository/public</url></mirror></mirrors></settings>' > /root/.m2/settings.xml
# npm 淘宝镜像加速
RUN npm config set registry https://registry.npmmirror.com
ENV TZ=Asia/Shanghai
WORKDIR /app
@@ -85,9 +92,13 @@ COPY --from=agent-builder /agent/bin/aiops-agent-darwin-arm64 /app/agent/bin/
COPY agent/deploy/install.sh /app/agent/deploy/
COPY agent/deploy/aiops-agent.service /app/agent/deploy/
# 启动脚本(注入 hosts DNS 缓存)
COPY backend/entrypoint.sh /app/
RUN chmod +x /app/entrypoint.sh
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
CMD ["./aiops"]
CMD ["./entrypoint.sh"]
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# AIOps 启动脚本 - 注入 DNS 缓存直连避免 Docker DNS 间歇性 SERVFAIL
# 署名 Logan.Li
# 注入常用镜像站的 hosts 直连
grep -q "maven.aliyun.com" /etc/hosts || echo "122.228.195.17 maven.aliyun.com" >> /etc/hosts
grep -q "registry.npmmirror.com" /etc/hosts || echo "122.228.223.11 registry.npmmirror.com" >> /etc/hosts
exec ./aiops "$@"
+324 -84
View File
@@ -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 = ""
+164
View File
@@ -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: "已授权"})
}
+201
View File
@@ -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: "移除成功"})
}
+266 -64
View File
@@ -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(&current, 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(&reg, *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
}
+93 -7
View File
@@ -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
+297 -35
View File
@@ -3,59 +3,321 @@ package models
import (
"time"
"gorm.io/gorm"
)
// AlertRule 告警规则模型
// ============================================
// AlertRule 告警规则(增强版 — 合并原V2特性)
// ============================================
// AlertRuleStatus 告警规则状态
type AlertRuleStatus string
const (
AlertRuleEnabled AlertRuleStatus = "enabled"
AlertRuleDisabled AlertRuleStatus = "disabled"
)
// AlertSeverity 告警级别
type AlertSeverity string
const (
AlertSeverityCritical AlertSeverity = "critical"
AlertSeverityWarning AlertSeverity = "warning"
AlertSeverityNotice AlertSeverity = "notice"
AlertSeverityInfo AlertSeverity = "info"
)
// AggFunction 聚合函数
type AggFunction string
const (
AggFunctionAvg AggFunction = "avg"
AggFunctionMin AggFunction = "min"
AggFunctionMax AggFunction = "max"
AggFunctionSum AggFunction = "sum"
AggFunctionCount AggFunction = "count"
AggFunctionLast AggFunction = "last"
AggFunctionP95 AggFunction = "p95"
AggFunctionP99 AggFunction = "p99"
)
// Operator 比较运算符
type Operator string
const (
OperatorGT Operator = ">"
OperatorGTE Operator = ">="
OperatorLT Operator = "<"
OperatorLTE Operator = "<="
OperatorEQ Operator = "=="
OperatorNE Operator = "!="
)
// AlertRule 告警规则(增强版)
type AlertRule struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
Type string `gorm:"size:32;not null" json:"type"` // metric, log, event
Source string `gorm:"size:64;not null" json:"source"` // prometheus, zabbix, custom
Expression string `gorm:"type:text;not null" json:"expression"`
Severity string `gorm:"size:32;default:warning" json:"severity"` // info, warning, critical
Duration int `json:"duration"` // 持续时间(秒)
Enabled bool `gorm:"default:true" json:"enabled"`
Labels string `gorm:"type:text" json:"labels"` // JSON
Annotations string `gorm:"type:text" json:"annotations"` // JSON
NotifyChannels string `gorm:"type:text" json:"notify_channels"` // JSON
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:规则名称" json:"name"`
Description string `gorm:"type:varchar(500);comment:规则描述" json:"description,omitempty"`
// ====== 原 V1 兼容字段 ======
Type string `gorm:"type:varchar(32);comment:规则类型: metric/log/event" json:"type,omitempty"`
Source string `gorm:"type:varchar(64);comment:数据源: prometheus/zabbix/custom" json:"source,omitempty"`
Expression string `gorm:"type:text;comment:PromQL/Zabbix表达式" json:"expression,omitempty"`
Severity string `gorm:"type:varchar(32);default:'warning';comment:告警级别" json:"severity"`
Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"`
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
// ====== V2 增强字段 ======
Status AlertRuleStatus `gorm:"type:varchar(16);default:'enabled';comment:规则状态" json:"status,omitempty"`
TargetType string `gorm:"type:varchar(32);comment:目标类型" json:"target_type,omitempty"`
TargetID uint `gorm:"index;comment:目标ID(0表示全部)" json:"target_id,omitempty"`
TargetTags string `gorm:"type:varchar(256);comment:目标标签筛选" json:"target_tags,omitempty"`
Conditions string `gorm:"type:text;comment:触发条件JSON数组" json:"conditions,omitempty"`
AggWindow int `gorm:"default:300;comment:聚合窗口(秒)" json:"agg_window,omitempty"`
AggFunction string `gorm:"type:varchar(16);default:'avg';comment:聚合函数" json:"agg_function,omitempty"`
RecoverCount int `gorm:"default:1;comment:恢复次数" json:"recover_count,omitempty"`
RecoverWindow int `gorm:"default:300;comment:恢复检查窗口(秒)" json:"recover_window,omitempty"`
Interval int `gorm:"default:60;comment:评估间隔(秒)" json:"interval,omitempty"`
CheckTimes int `gorm:"default:1;comment:连续触发次数" json:"check_times,omitempty"`
NotifyMode string `gorm:"type:varchar(16);default:'every';comment:通知模式: every/once/upgrade" json:"notify_mode,omitempty"`
ChannelIDs string `gorm:"type:varchar(256);comment:通知渠道ID列表JSON" json:"channel_ids,omitempty"`
NotifyChannels string `gorm:"type:text;comment:通知渠道JSON(兼容V1)" json:"notify_channels,omitempty"`
TemplateID uint `gorm:"comment:通知模板ID" json:"template_id,omitempty"`
UpgradeID uint `gorm:"comment:升级策略ID" json:"upgrade_id,omitempty"`
SilenceID uint `gorm:"comment:静默规则ID" json:"silence_id,omitempty"`
Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"`
Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 表名
func (AlertRule) TableName() string {
return "monitor_alert_rules"
}
// AlertEvent 告警事件模型
// ============================================
// AlertEvent 告警事件(增强版 — 合并原V2特性)
// ============================================
// AlertEventStatus 告警事件状态
type AlertEventStatus string
const (
AlertEventFiring AlertEventStatus = "firing"
AlertEventResolved AlertEventStatus = "resolved"
AlertEventSilenced AlertEventStatus = "silenced"
)
// AlertEvent 告警事件(增强版)
type AlertEvent struct {
ID uint `gorm:"primarykey" json:"id"`
RuleID uint `json:"rule_id"`
RuleName string `gorm:"size:128" json:"rule_name"`
Severity string `gorm:"size:32" json:"severity"`
Status string `gorm:"size:32;default:firing" json:"status"` // firing, resolved
Target string `gorm:"size:255" json:"target"`
Message string `gorm:"type:text" json:"message"`
Value float64 `json:"value"`
Labels string `gorm:"type:text" json:"labels"`
StartedAt time.Time `json:"started_at"`
ResolvedAt *time.Time `json:"resolved_at"`
Acknowledged bool `json:"acknowledged"`
AcknowledgedBy *uint `json:"acknowledged_by"`
AcknowledgedAt *time.Time `json:"acknowledged_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
RuleID uint `gorm:"not null;index:idx_rule_time;comment:规则ID" json:"rule_id"`
RuleName string `gorm:"type:varchar(128);comment:规则名称" json:"rule_name,omitempty"`
Severity string `gorm:"type:varchar(32);comment:告警级别" json:"severity,omitempty"`
Status string `gorm:"type:varchar(32);default:'firing';comment:事件状态" json:"status"`
// ====== 原V1兼容字段 ======
Target string `gorm:"type:varchar(255);comment:目标标签" json:"target,omitempty"`
Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"`
Value float64 `gorm:"comment:触发值" json:"value,omitempty"`
// ====== V2增强字段 ======
TargetID uint `gorm:"index;comment:目标ID" json:"target_id,omitempty"`
TargetName string `gorm:"type:varchar(128);comment:目标名称" json:"target_name,omitempty"`
TargetIP string `gorm:"type:varchar(64);comment:目标IP" json:"target_ip,omitempty"`
Title string `gorm:"type:varchar(256);comment:告警标题" json:"title,omitempty"`
MetricName string `gorm:"type:varchar(64);comment:指标名称" json:"metric_name,omitempty"`
Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"`
Operator string `gorm:"type:varchar(8);comment:运算符" json:"operator,omitempty"`
Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"`
Labels string `gorm:"type:text;comment:标签JSON" json:"labels,omitempty"`
Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"`
StartedAt time.Time `gorm:"not null;index:idx_rule_time;comment:触发时间" json:"started_at"`
ResolvedAt *time.Time `gorm:"comment:恢复时间" json:"resolved_at,omitempty"`
Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"`
LastEvalAt time.Time `gorm:"comment:最后评估时间" json:"last_eval_at,omitempty"`
NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count,omitempty"`
LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"`
UpgradeLevel int `gorm:"default:0;comment:当前升级级别" json:"upgrade_level,omitempty"`
Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"`
AcknowledgedBy *uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"`
AckComment string `gorm:"type:varchar(256);comment:确认备注" json:"ack_comment,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
// 关联
Rule *AlertRule `json:"rule,omitempty"`
Rule *AlertRule `json:"rule,omitempty" gorm:"foreignKey:RuleID"`
}
// TableName 表名
func (AlertEvent) TableName() string {
return "monitor_alert_events"
}
// ============================================
// AlertCondition 告警条件定义
// ============================================
type AlertCondition struct {
Metric string `json:"metric"`
Operator Operator `json:"operator"`
Threshold float64 `json:"threshold"`
Unit string `json:"unit,omitempty"`
Index string `json:"index,omitempty"`
LogicOp string `json:"logic_op,omitempty"`
AggFunc AggFunction `json:"agg_func,omitempty"`
Window int `json:"window,omitempty"`
}
func (AlertCondition) TableName() string {
return "monitor_alert_conditions"
}
type AlertConditionGroup struct {
Conditions []AlertCondition `json:"conditions"`
LogicOp string `json:"logic_op"`
}
// ============================================
// AlertNotifyTemplate 告警通知模板
// ============================================
type AlertNotifyTemplate struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
ChannelType string `gorm:"type:varchar(32);not null;comment:适用渠道类型: email/sms/webhook/dingtalk/wecom/feishu" json:"channel_type"`
TitleTemplate string `gorm:"type:varchar(256);comment:标题模板" json:"title_template,omitempty"`
BodyTemplate string `gorm:"type:text;comment:正文模板" json:"body_template,omitempty"`
Variables string `gorm:"type:text;comment:可用变量说明JSON" json:"variables,omitempty"`
RecoverTitleTemplate string `gorm:"type:varchar(256);comment:恢复标题模板" json:"recover_title_template,omitempty"`
RecoverBodyTemplate string `gorm:"type:text;comment:恢复正文模板" json:"recover_body_template,omitempty"`
IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
func (AlertNotifyTemplate) TableName() string {
return "monitor_alert_notify_templates"
}
type TemplateVariable struct {
Name string `json:"name"`
Description string `json:"description"`
Example string `json:"example"`
}
var DefaultTemplateVariables = []TemplateVariable{
{Name: "{{.RuleName}}", Description: "规则名称", Example: "CPU使用率告警"},
{Name: "{{.TargetName}}", Description: "目标名称", Example: "WebServer-01"},
{Name: "{{.TargetIP}}", Description: "目标IP", Example: "192.168.1.10"},
{Name: "{{.Severity}}", Description: "告警级别", Example: "critical"},
{Name: "{{.MetricName}}", Description: "指标名称", Example: "cpu_usage"},
{Name: "{{.Value}}", Description: "当前值", Example: "95.2"},
{Name: "{{.Threshold}}", Description: "阈值", Example: "90"},
{Name: "{{.Operator}}", Description: "运算符", Example: ">"},
{Name: "{{.Time}}", Description: "触发时间", Example: "2024-01-15 10:30:00"},
{Name: "{{.Duration}}", Description: "持续时间", Example: "5m"},
{Name: "{{.Description}}", Description: "规则描述", Example: "CPU使用率超过阈值"},
{Name: "{{.URL}}", Description: "详情链接", Example: "https://ezops.io/alerts/123"},
}
// ============================================
// AlertUpgrade + AlertUpgradeRecord 告警升级
// ============================================
type AlertUpgrade struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:策略名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
Rules string `gorm:"type:text;comment:升级规则JSON数组" json:"rules,omitempty"`
MaxLevel int `gorm:"default:3;comment:最大升级级别" json:"max_level"`
IsDefault bool `gorm:"default:false;comment:是否默认策略" json:"is_default"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
func (AlertUpgrade) TableName() string {
return "monitor_alert_upgrades"
}
type AlertUpgradeRule struct {
Level int `json:"level"`
Duration int `json:"duration"`
Severity string `json:"severity"`
ChannelIDs []string `json:"channel_ids"`
NotifyUsers []uint `json:"notify_users"`
RepeatInterval int `json:"repeat_interval"`
}
type AlertUpgradeRecord struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
AlertID uint `gorm:"not null;index;comment:告警ID" json:"alert_id"`
RuleID uint `gorm:"not null;comment:规则ID" json:"rule_id"`
FromLevel int `gorm:"comment:原级别" json:"from_level"`
ToLevel int `gorm:"comment:升级后级别" json:"to_level"`
UpgradedAt time.Time `gorm:"not null;comment:升级时间" json:"upgraded_at"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
}
func (AlertUpgradeRecord) TableName() string {
return "monitor_alert_upgrade_records"
}
// ============================================
// AlertSilence 告警静默规则
// ============================================
type AlertSilenceType string
const (
AlertSilenceOneTime AlertSilenceType = "onetime"
AlertSilenceDaily AlertSilenceType = "daily"
AlertSilenceWeekly AlertSilenceType = "weekly"
)
type AlertSilence struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:静默规则名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
RuleIDs string `gorm:"type:varchar(256);comment:匹配的告警规则ID列表JSON" json:"rule_ids,omitempty"`
TargetIDs string `gorm:"type:varchar(256);comment:匹配的目标ID列表JSON" json:"target_ids,omitempty"`
Matchers string `gorm:"type:text;comment:标签匹配条件JSON" json:"matchers,omitempty"`
SilenceType AlertSilenceType `gorm:"type:varchar(16);default:'onetime';comment:静默类型" json:"silence_type"`
StartTime time.Time `gorm:"not null;comment:开始时间" json:"start_time"`
EndTime time.Time `gorm:"not null;comment:结束时间" json:"end_time"`
Timezone string `gorm:"type:varchar(32);default:'Asia/Shanghai';comment:时区" json:"timezone"`
Weekdays string `gorm:"type:varchar(64);comment:生效星期(0-6)JSON数组" json:"weekdays,omitempty"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
func (AlertSilence) TableName() string {
return "monitor_alert_silences"
}
type AlertSilenceMatcher struct {
Name string `json:"name"`
Value string `json:"value"`
IsRegex bool `json:"is_regex"`
}
type AlertSilenceCheckResult struct {
IsSilenced bool `json:"is_silenced"`
SilenceID uint `json:"silence_id,omitempty"`
SilenceName string `json:"silence_name,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// NotifyChannel 通知渠道模型
type NotifyChannel struct {
ID uint `gorm:"primarykey" json:"id"`
-405
View File
@@ -1,405 +0,0 @@
package models
import (
"time"
)
// ============================================
// AlertRuleV2 增强告警规则
// ============================================
// AlertRuleV2Status 告警规则状态
type AlertRuleV2Status string
const (
AlertRuleV2Enabled AlertRuleV2Status = "enabled" // 启用
AlertRuleV2Disabled AlertRuleV2Status = "disabled" // 禁用
)
// AlertSeverity 告警级别
type AlertSeverity string
const (
AlertSeverityCritical AlertSeverity = "critical" // 严重
AlertSeverityWarning AlertSeverity = "warning" // 警告
AlertSeverityNotice AlertSeverity = "notice" // 通知
AlertSeverityInfo AlertSeverity = "info" // 信息
)
// AggFunction 聚合函数
type AggFunction string
const (
AggFunctionAvg AggFunction = "avg" // 平均值
AggFunctionMin AggFunction = "min" // 最小值
AggFunctionMax AggFunction = "max" // 最大值
AggFunctionSum AggFunction = "sum" // 求和
AggFunctionCount AggFunction = "count" // 计数
AggFunctionLast AggFunction = "last" // 最新值
AggFunctionP95 AggFunction = "p95" // 95分位
AggFunctionP99 AggFunction = "p99" // 99分位
)
// Operator 比较运算符
type Operator string
const (
OperatorGT Operator = ">" // 大于
OperatorGTE Operator = ">=" // 大于等于
OperatorLT Operator = "<" // 小于
OperatorLTE Operator = "<=" // 小于等于
OperatorEQ Operator = "==" // 等于
OperatorNE Operator = "!=" // 不等于
)
// AlertRuleV2 增强告警规则表
// 支持多条件组合、聚合函数、静默期等高级特性
type AlertRuleV2 struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:规则名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:规则描述" json:"description,omitempty"`
Severity AlertSeverity `gorm:"type:varchar(16);not null;comment:告警级别" json:"severity"`
Status AlertRuleV2Status `gorm:"type:varchar(16);default:'enabled';comment:规则状态" json:"status"`
// 关联目标
TargetType string `gorm:"type:varchar(32);comment:目标类型" json:"target_type,omitempty"`
TargetID uint `gorm:"index;comment:目标ID(0表示全部)" json:"target_id,omitempty"`
TargetTags string `gorm:"type:varchar(256);comment:目标标签筛选" json:"target_tags,omitempty"`
// 触发条件(JSON存储支持多条件)
Conditions string `gorm:"type:text;comment:触发条件JSON数组" json:"conditions,omitempty"`
// 聚合配置
AggWindow int `gorm:"default:300;comment:聚合窗口(秒)" json:"agg_window"`
AggFunction AggFunction `gorm:"type:varchar(16);default:'avg';comment:聚合函数" json:"agg_function"`
// 恢复配置
RecoverCount int `gorm:"default:1;comment:恢复次数(连续N次低于阈值触发恢复)" json:"recover_count"`
RecoverWindow int `gorm:"default:300;comment:恢复检查窗口(秒)" json:"recover_window"`
// 执行配置
Interval int `gorm:"default:60;comment:评估间隔(秒)" json:"interval"`
CheckTimes int `gorm:"default:1;comment:连续触发次数" json:"check_times"`
NotifyMode string `gorm:"type:varchar(16);default:'every';comment:通知模式: every/once/upgrade" json:"notify_mode"`
// 通知渠道
ChannelIDs string `gorm:"type:varchar(256);comment:通知渠道ID列表JSON" json:"channel_ids,omitempty"`
TemplateID uint `gorm:"comment:通知模板ID" json:"template_id,omitempty"`
// 升级策略
UpgradeID uint `gorm:"comment:升级策略ID" json:"upgrade_id,omitempty"`
// 静默策略
SilenceID uint `gorm:"comment:静默规则ID" json:"silence_id,omitempty"`
// 标签和注释
Labels string `gorm:"type:varchar(512);comment:标签JSON" json:"labels,omitempty"`
Annotations string `gorm:"type:text;comment:注释JSON(用于通知内容模板)" json:"annotations,omitempty"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (AlertRuleV2) TableName() string {
return "monitor_alert_rules_v2"
}
// AlertCondition 告警条件定义
type AlertCondition struct {
Metric string `json:"metric"` // 指标名称
Operator Operator `json:"operator"` // 比较运算符
Threshold float64 `json:"threshold"` // 阈值
Unit string `json:"unit"` // 单位
Index string `json:"index"` // 实例索引
LogicOp string `json:"logic_op"` // 逻辑关系: AND/OR
AggFunc AggFunction `json:"agg_func"` // 条件级聚合函数
Window int `json:"window"` // 条件级聚合窗口
}
// TableName 指定表名
func (AlertCondition) TableName() string {
return "monitor_alert_conditions"
}
// AlertConditionGroup 告警条件组
type AlertConditionGroup struct {
Conditions []AlertCondition `json:"conditions"` // 条件列表
LogicOp string `json:"logic_op"` // 组间逻辑: AND/OR
}
// RecoverConfig 告警恢复配置
type RecoverConfig struct {
Count int `json:"count"` // 连续恢复次数
Window int `json:"window"` // 恢复检查窗口(秒)
}
// NotifyMode 通知模式
type NotifyMode string
const (
NotifyModeEvery NotifyMode = "every" // 每次都通知
NotifyModeOnce NotifyMode = "once" // 仅通知一次
NotifyModeUpgrade NotifyMode = "upgrade" // 升级通知
)
// ============================================
// AlertNotifyTemplate 告警通知模板
// ============================================
// AlertNotifyTemplate 告警通知模板表
// 定义告警通知的内容模板,支持变量替换
type AlertNotifyTemplate struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:模板名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
ChannelType string `gorm:"type:varchar(32);not null;comment:适用渠道类型: email/sms/webhook/dingtalk/wecom/feishu" json:"channel_type"`
// 模板内容
TitleTemplate string `gorm:"type:varchar(256);comment:标题模板" json:"title_template,omitempty"`
BodyTemplate string `gorm:"type:text;comment:正文模板" json:"body_template,omitempty"`
// 变量定义
Variables string `gorm:"type:text;comment:可用变量说明JSON" json:"variables,omitempty"`
// 恢复通知模板
RecoverTitleTemplate string `gorm:"type:varchar(256);comment:恢复标题模板" json:"recover_title_template,omitempty"`
RecoverBodyTemplate string `gorm:"type:text;comment:恢复正文模板" json:"recover_body_template,omitempty"`
IsDefault bool `gorm:"default:false;comment:是否默认模板" json:"is_default"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (AlertNotifyTemplate) TableName() string {
return "monitor_alert_notify_templates"
}
// TemplateVariable 模板变量定义
type TemplateVariable struct {
Name string `json:"name"` // 变量名
Description string `json:"description"` // 描述
Example string `json:"example"` // 示例
}
// DefaultTemplateVariables 默认模板变量
var DefaultTemplateVariables = []TemplateVariable{
{Name: "{{.RuleName}}", Description: "规则名称", Example: "CPU使用率告警"},
{Name: "{{.TargetName}}", Description: "目标名称", Example: "WebServer-01"},
{Name: "{{.TargetIP}}", Description: "目标IP", Example: "192.168.1.10"},
{Name: "{{.Severity}}", Description: "告警级别", Example: "critical"},
{Name: "{{.MetricName}}", Description: "指标名称", Example: "cpu_usage"},
{Name: "{{.Value}}", Description: "当前值", Example: "95.2"},
{Name: "{{.Threshold}}", Description: "阈值", Example: "90"},
{Name: "{{.Operator}}", Description: "运算符", Example: ">"},
{Name: "{{.Time}}", Description: "触发时间", Example: "2024-01-15 10:30:00"},
{Name: "{{.Duration}}", Description: "持续时间", Example: "5m"},
{Name: "{{.Description}}", Description: "规则描述", Example: "CPU使用率超过阈值"},
{Name: "{{.URL}}", Description: "详情链接", Example: "https://ezops.io/alerts/123"},
}
// ============================================
// AlertUpgrade 告警升级策略
// ============================================
// AlertUpgrade 告警升级策略表
// 定义告警在不同阶段的升级策略
type AlertUpgrade struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:策略名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
// 升级规则(JSON存储支持多级升级)
Rules string `gorm:"type:text;comment:升级规则JSON数组" json:"rules,omitempty"`
// 执行配置
MaxLevel int `gorm:"default:3;comment:最大升级级别" json:"max_level"`
IsDefault bool `gorm:"default:false;comment:是否默认策略" json:"is_default"`
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (AlertUpgrade) TableName() string {
return "monitor_alert_upgrades"
}
// AlertUpgradeRule 告警升级规则
type AlertUpgradeRule struct {
Level int `json:"level"` // 升级级别
Duration int `json:"duration"` // 触发升级的持续时长(秒)
Severity string `json:"severity"` // 升级后的级别
ChannelIDs []string `json:"channel_ids"` // 升级后通知渠道
NotifyUsers []uint `json:"notify_users"` // 升级后通知用户
RepeatInterval int `json:"repeat_interval"` // 重复通知间隔(秒)
}
// AlertUpgradeRecord 告警升级记录
type AlertUpgradeRecord struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
AlertID uint `gorm:"not null;index;comment:告警ID" json:"alert_id"`
RuleID uint `gorm:"not null;comment:规则ID" json:"rule_id"`
FromLevel int `gorm:"comment:原级别" json:"from_level"`
ToLevel int `gorm:"comment:升级后级别" json:"to_level"`
UpgradedAt time.Time `gorm:"not null;comment:升级时间" json:"upgraded_at"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
}
// TableName 指定表名
func (AlertUpgradeRecord) TableName() string {
return "monitor_alert_upgrade_records"
}
// ============================================
// AlertSilence 告警静默规则
// ============================================
// AlertSilenceType 静默类型
type AlertSilenceType string
const (
AlertSilenceOneTime AlertSilenceType = "onetime" // 一次性静默
AlertSilenceDaily AlertSilenceType = "daily" // 每日重复
AlertSilenceWeekly AlertSilenceType = "weekly" // 每周重复
)
// AlertSilence 告警静默规则表
// 定义告警静默策略,支持一次性、周期性静默
type AlertSilence struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:静默规则名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
// 匹配条件
RuleIDs string `gorm:"type:varchar(256);comment:匹配的告警规则ID列表JSON" json:"rule_ids,omitempty"`
TargetIDs string `gorm:"type:varchar(256);comment:匹配的目标ID列表JSON" json:"target_ids,omitempty"`
Matchers string `gorm:"type:text;comment:标签匹配条件JSON" json:"matchers,omitempty"`
// 时间配置
SilenceType AlertSilenceType `gorm:"type:varchar(16);default:'onetime';comment:静默类型" json:"silence_type"`
StartTime time.Time `gorm:"not null;comment:开始时间" json:"start_time"`
EndTime time.Time `gorm:"not null;comment:结束时间" json:"end_time"`
Timezone string `gorm:"type:varchar(32);default:'Asia/Shanghai';comment:时区" json:"timezone"`
// 周期性配置
Weekdays string `gorm:"type:varchar(64);comment:生效星期(0-6)JSON数组" json:"weekdays,omitempty"`
// 状态
Status int `gorm:"default:1;comment:状态: 1启用 0禁用" json:"status"`
CreatedBy uint `gorm:"index;comment:创建人ID" json:"created_by"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
DeletedAt *time.Time `gorm:"index;comment:删除时间" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (AlertSilence) TableName() string {
return "monitor_alert_silences"
}
// AlertSilenceMatcher 静默匹配条件
type AlertSilenceMatcher struct {
Name string `json:"name"` // 标签名
Value string `json:"value"` // 标签值
IsRegex bool `json:"is_regex"` // 是否正则匹配
}
// AlertSilenceCheckResult 静默检查结果
type AlertSilenceCheckResult struct {
IsSilenced bool `json:"is_silenced"`
SilenceID uint `json:"silence_id,omitempty"`
SilenceName string `json:"silence_name,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// ============================================
// AlertEventV2 增强告警事件
// ============================================
// AlertEventV2Status 告警事件状态
type AlertEventV2Status string
const (
AlertEventV2Firing AlertEventV2Status = "firing" // 触发中
AlertEventV2Resolved AlertEventV2Status = "resolved" // 已恢复
AlertEventV2Silenced AlertEventV2Status = "silenced" // 已静默
)
// AlertEventV2 增强告警事件表
// 记录告警触发和恢复的全过程
type AlertEventV2 struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
RuleID uint `gorm:"not null;index:idx_rule_time;comment:规则ID" json:"rule_id"`
TargetID uint `gorm:"not null;index;comment:目标ID" json:"target_id"`
TargetName string `gorm:"type:varchar(128);comment:目标名称" json:"target_name,omitempty"`
TargetIP string `gorm:"type:varchar(64);comment:目标IP" json:"target_ip,omitempty"`
Severity AlertSeverity `gorm:"type:varchar(16);not null;index;comment:告警级别" json:"severity"`
Status AlertEventV2Status `gorm:"type:varchar(16);default:'firing';index;comment:事件状态" json:"status"`
Title string `gorm:"type:varchar(256);not null;comment:告警标题" json:"title"`
Message string `gorm:"type:text;comment:告警详情" json:"message,omitempty"`
// 触发值信息
MetricName string `gorm:"type:varchar(64);comment:指标名称" json:"metric_name,omitempty"`
Value float64 `gorm:"comment:触发值" json:"value,omitempty"`
Threshold float64 `gorm:"comment:阈值" json:"threshold,omitempty"`
Operator string `gorm:"type:varchar(8);comment:运算符" json:"operator,omitempty"`
Unit string `gorm:"type:varchar(32);comment:单位" json:"unit,omitempty"`
// 标签和注释
Labels string `gorm:"type:varchar(512);comment:标签JSON" json:"labels,omitempty"`
Annotations string `gorm:"type:text;comment:注释JSON" json:"annotations,omitempty"`
// 时间信息
StartedAt time.Time `gorm:"not null;index:idx_rule_time;comment:触发时间" json:"started_at"`
ResolvedAt *time.Time `gorm:"comment:恢复时间" json:"resolved_at,omitempty"`
Duration int `gorm:"comment:持续时间(秒)" json:"duration,omitempty"`
LastEvalAt time.Time `gorm:"comment:最后评估时间" json:"last_eval_at,omitempty"`
NotifyCount int `gorm:"default:0;comment:通知次数" json:"notify_count"`
LastNotifyAt *time.Time `gorm:"comment:最近通知时间" json:"last_notify_at,omitempty"`
UpgradeLevel int `gorm:"default:0;comment:当前升级级别" json:"upgrade_level"`
// 确认信息
Acknowledged bool `gorm:"default:false;comment:是否已确认" json:"acknowledged"`
AcknowledgedBy uint `gorm:"comment:确认人ID" json:"acknowledged_by,omitempty"`
AcknowledgedAt *time.Time `gorm:"comment:确认时间" json:"acknowledged_at,omitempty"`
AckComment string `gorm:"type:varchar(256);comment:确认备注" json:"ack_comment,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
// TableName 指定表名
func (AlertEventV2) TableName() string {
return "monitor_alert_events_v2"
}
// AlertRuleV2CreateRequest 告警规则创建请求
type AlertRuleV2CreateRequest struct {
Name string `json:"name" binding:"required,max=128"`
Description string `json:"description"`
Severity AlertSeverity `json:"severity" binding:"required"`
TargetType string `json:"target_type"`
TargetID uint `json:"target_id"`
TargetTags string `json:"target_tags"`
Conditions []AlertCondition `json:"conditions" binding:"required,min=1"`
AggWindow int `json:"agg_window"`
AggFunction AggFunction `json:"agg_function"`
RecoverCount int `json:"recover_count"`
Interval int `json:"interval"`
CheckTimes int `json:"check_times"`
NotifyMode string `json:"notify_mode"`
ChannelIDs []string `json:"channel_ids"`
TemplateID uint `json:"template_id"`
UpgradeID uint `json:"upgrade_id"`
SilenceID uint `json:"silence_id"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
}
// AlertRuleV2UpdateRequest 告警规则更新请求
type AlertRuleV2UpdateRequest struct {
Name string `json:"name" binding:"max=128"`
Description string `json:"description"`
Severity AlertSeverity `json:"severity"`
Status AlertRuleV2Status `json:"status"`
TargetType string `json:"target_type"`
TargetID uint `json:"target_id"`
TargetTags string `json:"target_tags"`
Conditions []AlertCondition `json:"conditions"`
AggWindow int `json:"agg_window"`
AggFunction AggFunction `json:"agg_function"`
RecoverCount int `json:"recover_count"`
Interval int `json:"interval"`
CheckTimes int `json:"check_times"`
NotifyMode string `json:"notify_mode"`
ChannelIDs []string `json:"channel_ids"`
TemplateID uint `json:"template_id"`
UpgradeID uint `json:"upgrade_id"`
SilenceID uint `json:"silence_id"`
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
}
+107 -8
View File
@@ -10,6 +10,7 @@ type Pipeline struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"`
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
JobName string `gorm:"size:128;uniqueIndex;comment:Job名称(webhook URL标识, 如 cex-admin-server)" json:"job_name,omitempty"`
Description string `gorm:"size:500" json:"description"`
Type string `gorm:"size:32;default:deploy" json:"type"` // build, deploy, test
Source string `gorm:"size:255" json:"source"` // Git仓库地址
@@ -18,13 +19,51 @@ type Pipeline struct {
Stages string `gorm:"type:text" json:"stages"` // JSON
Variables string `gorm:"type:text" json:"variables"` // JSON
Enabled bool `gorm:"default:true" json:"enabled"`
LastRunAt *time.Time `json:"last_run_at"`
LastStatus string `gorm:"size:32" json:"last_status"`
RunCount int `gorm:"default:0" json:"run_count"`
SuccessCount int `gorm:"default:0" json:"success_count"`
FailCount int `gorm:"default:0" json:"fail_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 部署模式
DeployMode string `gorm:"size:16;default:branch;comment:部署模式 branch/tag" json:"deploy_mode"`
DeployTarget string `gorm:"size:16;default:ssh;comment:部署目标 ssh/k8s" json:"deploy_target"`
// SSH 部署目标服务器 (JSON: [1,2,3])
ServerIDs string `gorm:"type:text;comment:SSH服务器ID列表(JSON)" json:"server_ids,omitempty"`
// K8s 部署目标
K8sClusterID *uint `json:"k8s_cluster_id,omitempty"` // FK → kubernetes_clusters
K8sNamespace string `gorm:"size:128;comment:K8s部署命名空间" json:"k8s_namespace,omitempty"`
// 仓库和镜像仓库关联(旧字段,待废弃)
RepoID *uint `json:"repo_id"` // FK → deploy_pipeline_repos
RegistryID *uint `json:"registry_id"` // FK → deploy_pipeline_registries
// 凭证关联(直接关联凭证 ID)
GitCredentialID *uint `json:"git_credential_id"` // FK → iam_credentials (Git Token)
RegistryCredentialID *uint `json:"registry_credential_id"` // FK → iam_credentials (Docker Password)
// 环境分组
EnvironmentID *uint `json:"environment_id"` // FK → deploy_environments
// 触发配置
TriggerMode string `gorm:"type:varchar(32);default:'manual';comment:触发方式: git_push/cron/webhook/manual" json:"trigger_mode"`
WatchBranches string `gorm:"type:varchar(256);comment:监听分支(逗号分隔, 支持通配)" json:"watch_branches,omitempty"`
ExcludeBranch string `gorm:"type:varchar(256);comment:排除分支" json:"exclude_branch,omitempty"`
CronExpr string `gorm:"type:varchar(64);comment:Cron 表达式" json:"cron_expr,omitempty"`
WebhookToken string `gorm:"type:varchar(128);comment:Webhook 签名 Token" json:"webhook_token,omitempty"`
// 缓存配置
CachePaths string `gorm:"type:text;comment:缓存路径(逗号分隔)" json:"cache_paths,omitempty"`
LastRunAt *time.Time `json:"last_run_at"`
LastStatus string `gorm:"size:32" json:"last_status"`
RunCount int `gorm:"default:0" json:"run_count"`
SuccessCount int `gorm:"default:0" json:"success_count"`
FailCount int `gorm:"default:0" json:"fail_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 关联
Repo *PipelineRepo `json:"repo,omitempty" gorm:"foreignKey:RepoID"`
Registry *PipelineRegistry `json:"registry,omitempty" gorm:"foreignKey:RegistryID"`
Environment *DeployEnvironment `json:"environment,omitempty" gorm:"foreignKey:EnvironmentID"`
}
// TableName 表名
@@ -44,6 +83,7 @@ type PipelineRun struct {
Author string `gorm:"size:64" json:"author"`
ImageTag string `gorm:"size:255" json:"image_tag"` // 构建产物镜像标签
ArtifactURL string `gorm:"size:500" json:"artifact_url"` // 构建产物地址
WorkspacePath string `gorm:"size:500" json:"workspace_path"` // 工作区路径
Duration int `json:"duration"` // 秒
Stages string `gorm:"type:text" json:"stages"` // JSON
Logs string `gorm:"type:longtext" json:"logs"`
@@ -98,6 +138,7 @@ func (AppRelease) TableName() string {
type CreatePipelineRequest struct {
Name string `json:"name" binding:"required"`
Code string `json:"code" binding:"required"`
JobName string `json:"job_name"` // Webhook Job名称
Description string `json:"description"`
Type string `json:"type" binding:"omitempty,oneof=build deploy test"`
Source string `json:"source"`
@@ -105,11 +146,69 @@ type CreatePipelineRequest struct {
Dockerfile string `json:"dockerfile"`
Stages string `json:"stages"`
Variables string `json:"variables"`
// 部署模式
DeployMode string `json:"deploy_mode"` // branch / tag
DeployTarget string `json:"deploy_target"` // ssh / k8s
ServerIDs string `json:"server_ids"` // SSH 服务器ID JSON [1,2,3]
K8sClusterID *uint `json:"k8s_cluster_id"`
K8sNamespace string `json:"k8s_namespace"`
RepoID *uint `json:"repo_id"`
RegistryID *uint `json:"registry_id"`
// 凭证关联
GitCredentialID *uint `json:"git_credential_id"`
RegistryCredentialID *uint `json:"registry_credential_id"`
EnvironmentID *uint `json:"environment_id"`
// 触发配置
TriggerMode string `json:"trigger_mode"`
WatchBranches string `json:"watch_branches"`
ExcludeBranch string `json:"exclude_branch"`
CronExpr string `json:"cron_expr"`
WebhookToken string `json:"webhook_token"`
// 缓存路径
CachePaths string `json:"cache_paths"`
}
// UpdatePipelineRequest 更新流水线请求
type UpdatePipelineRequest struct {
Name string `json:"name"`
JobName string `json:"job_name"`
Description string `json:"description"`
Type string `json:"type"`
Source string `json:"source"`
Branch string `json:"branch"`
Dockerfile string `json:"dockerfile"`
Stages string `json:"stages"`
Variables string `json:"variables"`
DeployMode string `json:"deploy_mode"`
DeployTarget string `json:"deploy_target"`
ServerIDs string `json:"server_ids"`
K8sClusterID *uint `json:"k8s_cluster_id"`
K8sNamespace string `json:"k8s_namespace"`
RepoID *uint `json:"repo_id"`
RegistryID *uint `json:"registry_id"`
// 凭证关联
GitCredentialID *uint `json:"git_credential_id"`
RegistryCredentialID *uint `json:"registry_credential_id"`
EnvironmentID *uint `json:"environment_id"`
// 触发配置
TriggerMode string `json:"trigger_mode"`
WatchBranches string `json:"watch_branches"`
ExcludeBranch string `json:"exclude_branch"`
CronExpr string `json:"cron_expr"`
WebhookToken string `json:"webhook_token"`
// 缓存
CachePaths string `json:"cache_paths"`
Enabled *bool `json:"enabled"`
}
// TriggerPipelineRequest 触发流水线请求
type TriggerPipelineRequest struct {
Branch string `json:"branch"`
Branch string `json:"branch"`
Tag string `json:"tag"` // 发布 Tag
Variables map[string]string `json:"variables"`
}
+183 -29
View File
@@ -4,44 +4,198 @@ import (
"time"
)
// Credential 凭据管理模型(SSH密钥/API Token/密码等统一管理)
// Credential 凭据管理模型 — 统一管理 SSH/Docker/Token/AKSK/数据库/API 等敏感信息
// 设计参考:opsx-web Secret 模型,按类型动态切换字段
// 核心加密字段 content 存储类型特有数据(JSON),API 不返回
//
// 类型与内容 JSON 映射:
// ssh → {"private_key":"...","public_key":"...","ssh_user":"...","ssh_port":22}
// docker → {"server":"...","username":"...","password":"..."}
// tls → {"cert":"...","key":"...","ca":"..."}
// aksk → {"provider":"...","access_key":"...","secret_key":"...","region":"..."}
// database → {"db_type":"...","host":"...","port":3306,"db_name":"...","username":"...","password":"..."}
// api → {"key":"...","endpoint":"..."}
// generic → {"key":"...","value":"..."}
type Credential struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"` // 凭据名称
Type string `gorm:"size:32;not null" json:"type"` // ssh_key, api_token, password, access_key
Content string `gorm:"type:text;not null" json:"-"` // AES加密后的内容,API不返回
Description string `gorm:"size:500" json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:128;not null" json:"name"` // 凭据名称
VariableName string `gorm:"size:64;index" json:"variable_name,omitempty"` // 环境变量别名 e.g. MYSQL_PROD
SecretType string `gorm:"size:32;not null;index" json:"secret_type"` // ssh/docker/tls/aksk/database/api/generic
Summary string `gorm:"size:255" json:"summary,omitempty"` // 关键信息摘要(脱敏展示)
Content string `gorm:"type:text;not null" json:"-"` // AES 加密后的 JSON,API 不返回
Tags string `gorm:"type:text" json:"tags,omitempty"` // JSON 数组: ["生产环境","阿里云"]
Description string `gorm:"size:500" json:"description,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"` // 过期时间
IsActive bool `gorm:"default:true" json:"is_active"` // 启用/禁用
EnvPrefix string `gorm:"size:32" json:"env_prefix,omitempty"` // 引用 ID(可复制)
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Credential) TableName() string {
return "iam_credentials"
}
// CredentialCreateRequest 创建凭据请求
type CredentialCreateRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required,oneof=ssh_key api_token password access_key"`
Content string `json:"content" binding:"required"` // 明文,后端加密存储
Description string `json:"description"`
// ────── 类型定义 ──────
// SecretType SSH 凭据 content
type SSHSecretContent struct {
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"`
SSHUser string `json:"ssh_user"`
SSHPort int `json:"ssh_port"`
Password string `json:"password,omitempty"`
}
// CredentialUpdateRequest 更新凭据请求
type CredentialUpdateRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required,oneof=ssh_key api_token password access_key"`
Content string `json:"content"` // 空则不更新内容
Description string `json:"description"`
// DockerSecretContent Docker 凭据 content
type DockerSecretContent struct {
Server string `json:"server"`
Username string `json:"username"`
Password string `json:"password"`
}
// CredentialListResponse 列表返回(内容脱敏)
type CredentialListResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Preview string `json:"preview"` // 内容预览(前8+后4字符)
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// TLSSecretContent TLS 证书凭据 content
type TLSSecretContent struct {
Cert string `json:"cert"`
Key string `json:"key"`
CA string `json:"ca"`
}
// AKSKSecretContent AKSK 凭据 content
type AKSKSecretContent struct {
Provider string `json:"provider"`
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Region string `json:"region,omitempty"`
}
// DatabaseSecretContent 数据库凭据 content
type DatabaseSecretContent struct {
DBType string `json:"db_type"`
Host string `json:"host"`
Port int `json:"port"`
DBName string `json:"db_name"`
Username string `json:"username"`
Password string `json:"password"`
}
// APISecretContent API 密钥凭据 content
type APISecretContent struct {
Key string `json:"api_key"`
Endpoint string `json:"endpoint,omitempty"`
}
// GenericSecretContent 通用凭据 content
type GenericSecretContent struct {
Key string `json:"generic_key"`
Value string `json:"generic_value"`
}
// ────── 请求/响应 ──────
// CreateSecretRequest 创建凭据请求
type CreateSecretRequest struct {
Name string `json:"name" binding:"required"`
VariableName string `json:"variable_name"`
SecretType string `json:"secret_type" binding:"required,oneof=ssh docker tls aksk database api generic"`
Tags []string `json:"tags"`
Description string `json:"description"`
ExpiresAt *time.Time `json:"expires_at"`
IsActive *bool `json:"is_active"`
// 类型特有字段(明文,后端组装成 JSON 加密存储)
// SSH
SSHPrivateKey string `json:"ssh_private_key,omitempty"`
SSHPublicKey string `json:"ssh_public_key,omitempty"`
SSHUser string `json:"ssh_user,omitempty"`
SSHPort int `json:"ssh_port,omitempty"`
SSHPassword string `json:"ssh_password,omitempty"`
// Docker
DockerServer string `json:"docker_server,omitempty"`
DockerUsername string `json:"docker_username,omitempty"`
DockerPassword string `json:"docker_password,omitempty"`
// TLS
TLSCert string `json:"tls_cert,omitempty"`
TLSKey string `json:"tls_key,omitempty"`
TLSCA string `json:"tls_ca,omitempty"`
// AKSK
AKSKProvider string `json:"aksk_provider,omitempty"`
AKSKAccessKey string `json:"aksk_access_key,omitempty"`
AKSKSecretKey string `json:"aksk_secret_key,omitempty"`
AKSKRegion string `json:"aksk_region,omitempty"`
// Database
DBType string `json:"db_type,omitempty"`
DBHost string `json:"db_host,omitempty"`
DBPort int `json:"db_port,omitempty"`
DBName string `json:"db_name,omitempty"`
DBUsername string `json:"db_username,omitempty"`
DBPassword string `json:"db_password,omitempty"`
// API
APIKey string `json:"api_key,omitempty"`
APIEndpoint string `json:"api_endpoint,omitempty"`
// Generic
GenericKey string `json:"generic_key,omitempty"`
GenericValue string `json:"generic_value,omitempty"`
}
// UpdateSecretRequest 更新凭据请求
type UpdateSecretRequest struct {
Name *string `json:"name"`
VariableName *string `json:"variable_name"`
Tags []string `json:"tags"`
Description *string `json:"description"`
ExpiresAt *time.Time `json:"expires_at"`
IsActive *bool `json:"is_active"`
// 类型特有字段 — 传了才更新
SSHPrivateKey *string `json:"ssh_private_key,omitempty"`
SSHPublicKey *string `json:"ssh_public_key,omitempty"`
SSHUser *string `json:"ssh_user,omitempty"`
SSHPort *int `json:"ssh_port,omitempty"`
SSHPassword *string `json:"ssh_password,omitempty"`
DockerServer *string `json:"docker_server,omitempty"`
DockerUsername *string `json:"docker_username,omitempty"`
DockerPassword *string `json:"docker_password,omitempty"`
TLSCert *string `json:"tls_cert,omitempty"`
TLSKey *string `json:"tls_key,omitempty"`
TLSCA *string `json:"tls_ca,omitempty"`
AKSKProvider *string `json:"aksk_provider,omitempty"`
AKSKAccessKey *string `json:"aksk_access_key,omitempty"`
AKSKSecretKey *string `json:"aksk_secret_key,omitempty"`
AKSKRegion *string `json:"aksk_region,omitempty"`
DBType *string `json:"db_type,omitempty"`
DBHost *string `json:"db_host,omitempty"`
DBPort *int `json:"db_port,omitempty"`
DBName *string `json:"db_name,omitempty"`
DBUsername *string `json:"db_username,omitempty"`
DBPassword *string `json:"db_password,omitempty"`
APIKey *string `json:"api_key,omitempty"`
APIEndpoint *string `json:"api_endpoint,omitempty"`
GenericKey *string `json:"generic_key,omitempty"`
GenericValue *string `json:"generic_value,omitempty"`
}
// SecretListResponse 列表返回(内容脱敏)
type SecretListResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
VariableName string `json:"variable_name,omitempty"`
SecretType string `json:"secret_type"`
Summary string `json:"summary,omitempty"`
Tags []string `json:"tags,omitempty"`
Description string `json:"description,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
IsActive bool `json:"is_active"`
EnvPrefix string `json:"env_prefix,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// SecretListQuery 列表查询参数
type SecretListQuery struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
SecretType string `form:"secret_type"`
Tags string `form:"tags"` // 逗号分隔
Search string `form:"search"`
}
+193
View File
@@ -0,0 +1,193 @@
package models
import "time"
// ============================================
// PipelineRepo Git 仓库认证配置
// ============================================
// RepoAuthType 仓库认证方式
type RepoAuthType string
const (
RepoAuthToken RepoAuthType = "token" // Personal Access Token
RepoAuthSSH RepoAuthType = "ssh" // SSH Key
RepoAuthPassword RepoAuthType = "password" // 用户名+密码
)
// PipelineRepo Git 仓库认证配置表
type PipelineRepo struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
URL string `gorm:"type:varchar(512);not null;comment:仓库 URL" json:"url"`
AuthType RepoAuthType `gorm:"type:varchar(16);default:'token';comment:认证方式: token/ssh/password" json:"auth_type"`
CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"`
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
// TableName 表名
func (PipelineRepo) TableName() string {
return "deploy_pipeline_repos"
}
// ============================================
// PipelineRegistry 镜像仓库认证配置
// ============================================
// RegistryAuthType 镜像仓库认证方式
type RegistryAuthType string
const (
RegistryAuthToken RegistryAuthType = "token" // Access Token
RegistryAuthPassword RegistryAuthType = "password" // 用户名+密码
)
// PipelineRegistry 镜像仓库认证配置表
type PipelineRegistry struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(128);not null;comment:配置名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
RegistryURL string `gorm:"type:varchar(512);not null;comment:镜像仓库地址" json:"registry_url"`
Namespace string `gorm:"type:varchar(128);comment:默认命名空间" json:"namespace,omitempty"`
AuthType RegistryAuthType `gorm:"type:varchar(16);default:'password';comment:认证方式: token/password" json:"auth_type"`
CredentialID *uint `gorm:"index;comment:凭据ID(关联 iam_credentials)" json:"credential_id,omitempty"`
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
// TableName 表名
func (PipelineRegistry) TableName() string {
return "deploy_pipeline_registries"
}
// ============================================
// PipelineStage 流水线阶段定义(JSON)
// ============================================
// PipelineStage 一个阶段包含多个任务
type PipelineStage struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // source/build/deploy/test/custom
Tasks []PipelineTask `json:"tasks"`
}
// PipelineTask 阶段内的任务
type PipelineTask struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // build/deploy/test/custom
Icon string `json:"icon,omitempty"`
Config map[string]string `json:"config,omitempty"`
Script string `json:"script,omitempty"`
ScriptType string `json:"script_type,omitempty"` // shell/python
}
// ============================================
// DeployEnvironment 发布环境分组
// ============================================
type EnvironmentType string
const (
EnvProduction EnvironmentType = "production"
EnvStaging EnvironmentType = "staging"
EnvTesting EnvironmentType = "testing"
EnvDev EnvironmentType = "development"
)
// DeployEnvironment 环境分组表(生产/测试/预发布/开发)
type DeployEnvironment struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(64);not null;uniqueIndex;comment:环境名称" json:"name"`
DisplayName string `gorm:"type:varchar(128);not null;comment:显示名称" json:"display_name"`
Type EnvironmentType `gorm:"type:varchar(16);not null;index;comment:环境类型: production/staging/testing/development" json:"type"`
Color string `gorm:"type:varchar(16);default:'#3B82F6';comment:标识颜色" json:"color"`
SortOrder int `gorm:"default:0;comment:排序" json:"sort_order"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
Enabled bool `gorm:"default:true;comment:是否启用" json:"enabled"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
}
func (DeployEnvironment) TableName() string {
return "deploy_environments"
}
// ============================================
// DeployPipelineMember 流水线成员
// ============================================
type MemberRole string
const (
MemberOwner MemberRole = "owner" // 拥有者
MemberEditor MemberRole = "editor" // 编辑者
MemberViewer MemberRole = "viewer" // 查看者
)
// DeployPipelineMember 流水线成员表
// 支持两种授权方式:直接用户授权(username 非空)和用户组授权(group_id 非空)
type DeployPipelineMember struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
PipelineID uint `gorm:"index:idx_pipeline_user,unique,priority:1;index:idx_pipeline_group,unique,priority:1;not null;comment:流水线ID" json:"pipeline_id"`
Username *string `gorm:"type:varchar(64);index:idx_pipeline_user,unique,priority:2;comment:用户名(直接授权)" json:"username,omitempty"`
GroupID *uint `gorm:"index:idx_pipeline_group,unique,priority:2;comment:用户组ID(组授权)" json:"group_id,omitempty"`
Role MemberRole `gorm:"type:varchar(16);default:'editor';comment:角色: owner/editor/viewer" json:"role"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
// 关联
Pipeline *Pipeline `json:"pipeline,omitempty" gorm:"foreignKey:PipelineID"`
Group *DeployUserGroup `json:"group,omitempty" gorm:"foreignKey:GroupID"`
}
func (DeployPipelineMember) TableName() string {
return "deploy_pipeline_members"
}
// ============================================
// DeployUserGroup 发布系统用户组
// ============================================
// DeployUserGroup 用户组(用于流水线权限分组授权)
type DeployUserGroup struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
Name string `gorm:"type:varchar(64);uniqueIndex;not null;comment:组名称" json:"name"`
Description string `gorm:"type:varchar(256);comment:描述" json:"description,omitempty"`
CreatedBy string `gorm:"type:varchar(64);comment:创建人" json:"created_by,omitempty"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"not null;comment:更新时间" json:"updated_at"`
// 关联
Members []DeployUserGroupMember `json:"members,omitempty" gorm:"foreignKey:GroupID"`
}
func (DeployUserGroup) TableName() string {
return "deploy_user_groups"
}
// ============================================
// DeployUserGroupMember 用户组成员
// ============================================
// DeployUserGroupMember 用户组成员关联表
type DeployUserGroupMember struct {
ID uint `gorm:"primaryKey;autoIncrement;comment:主键ID" json:"id"`
GroupID uint `gorm:"uniqueIndex:idx_group_user;not null;comment:组ID" json:"group_id"`
Username string `gorm:"type:varchar(64);uniqueIndex:idx_group_user;not null;comment:用户名" json:"username"`
CreatedAt time.Time `gorm:"not null;comment:创建时间" json:"created_at"`
// 关联
Group *DeployUserGroup `json:"group,omitempty" gorm:"foreignKey:GroupID"`
}
func (DeployUserGroupMember) TableName() string {
return "deploy_user_group_members"
}
+63 -4
View File
@@ -69,6 +69,40 @@ func (Permission) TableName() string {
return "iam_permissions"
}
// ItemRole Jenkins-style Item Role — regex pipeline code matching
type ItemRole struct {
ID uint `gorm:"primarykey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
Code string `gorm:"size:64;uniqueIndex;not null" json:"code"`
Pattern string `gorm:"size:255;not null;comment:正则表达式匹配流水线code" json:"pattern"`
Role string `gorm:"size:16;default:'editor';comment:角色owner/editor/viewer" json:"role"`
Description string `gorm:"size:255" json:"description"`
Status int `gorm:"default:1;comment:0禁用1启用" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName 表名
func (ItemRole) TableName() string {
return "iam_item_roles"
}
// UserItemRole user ↔ item_role association
type UserItemRole struct {
ID uint `gorm:"primarykey" json:"id"`
UserID uint `gorm:"uniqueIndex:idx_user_item_role;not null" json:"user_id"`
ItemRoleID uint `gorm:"uniqueIndex:idx_user_item_role;not null" json:"item_role_id"`
CreatedAt time.Time `json:"created_at"`
User *User `json:"user,omitempty" gorm:"foreignKey:UserID"`
ItemRole *ItemRole `json:"item_role,omitempty" gorm:"foreignKey:ItemRoleID"`
}
// TableName 表名
func (UserItemRole) TableName() string {
return "iam_user_item_roles"
}
// LoginRequest 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
@@ -111,11 +145,36 @@ type CreateRoleRequest struct {
// UpdateRoleRequest 更新角色请求 // Logan.Li
type UpdateRoleRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=64"`
Code string `json:"code" binding:"omitempty,min=1,max=64"`
Name string `json:"name" binding:"omitempty,min=1,max=64"`
Code string `json:"code" binding:"omitempty,min=1,max=64"`
Description *string `json:"description"`
Status *int `json:"status"`
PermissionIDs []uint `json:"permission_ids"`
Status *int `json:"status"`
PermissionIDs []uint `json:"permission_ids"`
}
// CreateItemRoleRequest 创建Item Role请求
type CreateItemRoleRequest struct {
Name string `json:"name" binding:"required,min=1,max=64"`
Code string `json:"code" binding:"required,min=1,max=64"`
Pattern string `json:"pattern" binding:"required,max=255"`
Role string `json:"role" binding:"oneof=owner editor viewer"`
Description string `json:"description"`
}
// UpdateItemRoleRequest 更新Item Role请求
type UpdateItemRoleRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=64"`
Code string `json:"code" binding:"omitempty,min=1,max=64"`
Pattern string `json:"pattern" binding:"omitempty,max=255"`
Role string `json:"role" binding:"omitempty,oneof=owner editor viewer"`
Description string `json:"description"`
Status *int `json:"status"`
}
// AssignItemRoleRequest 分配Item Role给用户
type AssignItemRoleRequest struct {
UserID uint `json:"user_id" binding:"required"`
ItemRoleID uint `json:"item_role_id" binding:"required"`
}
// ChangePasswordRequest 修改密码请求
+13
View File
@@ -119,6 +119,19 @@ func AutoMigrate(db *gorm.DB) error {
&models.PrometheusDataSource{}, // Prometheus 数据源配置 // Logan.Li
&models.ZabbixDataSource{}, // Zabbix 数据源配置 // Logan.Li
&models.Credential{}, // 凭据管理(SSH密钥/API Token) // Logan.Li
&models.PipelineRepo{}, // Git 仓库认证 // Logan.Li
&models.PipelineRegistry{}, // 镜像仓库认证 // Logan.Li
&models.DeployEnvironment{}, // 发布环境分组 // Logan.Li
&models.DeployPipelineMember{}, // 流水线成员 // Logan.Li
&models.DeployUserGroup{}, // 用户组 // Logan.Li
&models.DeployUserGroupMember{}, // 用户组成员 // Logan.Li
&models.ItemRole{}, // Jenkins-style Item Role (regex pipeline matching) // Logan.Li
&models.UserItemRole{}, // User ↔ ItemRole association // Logan.Li
&models.AlertCondition{}, // 告警条件 // Logan.Li
&models.AlertNotifyTemplate{}, // 告警通知模板 // Logan.Li
&models.AlertUpgrade{}, // 告警升级策略 // Logan.Li
&models.AlertUpgradeRecord{}, // 告警升级记录 // Logan.Li
&models.AlertSilence{}, // 告警静默 // Logan.Li
}
var lastErr error
+4
View File
@@ -93,3 +93,7 @@ func (r *goRedisCache) ZRangeByScore(ctx context.Context, key string, min, max s
func (r *goRedisCache) ZRemRangeByScore(ctx context.Context, key string, min, max string) error {
return r.client.ZRemRangeByScore(ctx, key, min, max).Err()
}
func (r *goRedisCache) Publish(ctx context.Context, channel string, message string) error {
return r.client.Publish(ctx, channel, message).Err()
}
+3
View File
@@ -50,6 +50,9 @@ type Cache interface {
Ping(ctx context.Context) error
Close() error
// Pub/Sub(实时日志推送)
Publish(ctx context.Context, channel string, message string) error
// 有序集合操作(热数据指标存储,仅 go-redis 后端完整支持)
ZAdd(ctx context.Context, key string, member SortedSetMember) error
ZRangeByScore(ctx context.Context, key string, min, max string) ([]SortedSetMember, error)
+6
View File
@@ -111,3 +111,9 @@ func (r *redkaCache) ZRangeByScore(ctx context.Context, key string, min, max str
func (r *redkaCache) ZRemRangeByScore(ctx context.Context, key string, min, max string) error {
return errSortedSetNotSupported
}
var errPubSubNotSupported = fmt.Errorf("Pub/Sub is not supported by the redka backend; use go-redis (driver=redis)")
func (r *redkaCache) Publish(ctx context.Context, channel string, message string) error {
return errPubSubNotSupported
}
Executable
BIN
View File
Binary file not shown.